Skip to main content

Payment Plugin Development

Payment plugins integrate third-party payment gateways into the J2Commerce checkout flow. They are Joomla plugins in the j2commerce group and implement an event-driven architecture using SubscriberInterface. A single plugin handles the complete payment lifecycle: registration, form rendering, processing, voids, refunds, stored payment methods, and subscription renewals.


Overview​

Payment plugins participate in checkout through a series of J2Commerce events. The core component fires events at each stage; your plugin responds only to the events it needs. This decoupling means a minimal plugin can be just three event handlers while a full-featured gateway implements a dozen or more.

Payment plugins also respond to admin events for void, refund, and charging stored cards, and to frontend events for the My Account saved-cards tab.


Getting Started — MVP Plugin​

The minimum viable payment plugin requires five files. This is a complete, copy-paste-ready starting point.

File Structure​

plugins/j2commerce/payment_mygateway/
├── payment_mygateway.xml # XML manifest
├── services/
│ └── provider.php # DI service provider
├── src/
│ └── Extension/
│ └── PaymentMygateway.php # Main plugin class
├── tmpl/
│ ├── prepayment.php # Checkout payment form
│ └── postpayment.php # Post-payment confirmation
└── language/
└── en-GB/
├── plg_j2commerce_payment_mygateway.ini
└── plg_j2commerce_payment_mygateway.sys.ini

XML Manifest​

<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="j2commerce" method="upgrade">
<name>PLG_J2COMMERCE_PAYMENT_MYGATEWAY</name>
<version>1.0.0</version>
<creationDate>January 2025</creationDate>
<author>Your Name</author>
<copyright>(C)2025 Your Company. All rights reserved.</copyright>
<license>GNU General Public License version 2 or later</license>
<authorEmail>support@yourcompany.com</authorEmail>
<authorUrl>https://yourcompany.com</authorUrl>
<description>PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DESC</description>
<namespace path="src">YourCompany\Plugin\J2Commerce\PaymentMygateway</namespace>

<files>
<folder plugin="payment_mygateway">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>

<languages folder="language">
<language tag="en-GB">en-GB/plg_j2commerce_payment_mygateway.ini</language>
<language tag="en-GB">en-GB/plg_j2commerce_payment_mygateway.sys.ini</language>
</languages>

<config>
<fields name="params">
<fieldset name="basic" label="PLG_J2COMMERCE_PAYMENT_MYGATEWAY">

<field
name="payment_method_type"
type="hidden"
default="PLG_J2COMMERCE_PAYMENT_METHOD"
/>

<field
name="display_name"
type="text"
label="COM_J2COMMERCE_PLUGIN_DISPLAY_NAME"
description="COM_J2COMMERCE_PLUGIN_DISPLAY_NAME_DESC"
default="Pay with MyGateway"
/>
<field
name="display_image"
type="media"
label="COM_J2COMMERCE_PLUGIN_DISPLAY_IMAGE"
description="COM_J2COMMERCE_PLUGIN_DISPLAY_IMAGE_DESC"
/>

<field
name="api_key"
type="text"
label="PLG_J2COMMERCE_PAYMENT_MYGATEWAY_API_KEY"
size="80"
/>
<field
name="sandbox"
type="radio"
label="PLG_J2COMMERCE_PAYMENT_MYGATEWAY_SANDBOX"
layout="joomla.form.field.radio.switcher"
filter="integer"
default="0"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>

<field type="spacer" />

<field
name="subtemplate"
type="PluginSubtemplate"
plugin_group="j2commerce"
plugin_element="payment_mygateway"
label="COM_J2COMMERCE_PLUGIN_SUBTEMPLATE"
description="COM_J2COMMERCE_PLUGIN_SUBTEMPLATE_DESC"
default=""
addfieldprefix="J2Commerce\Component\J2commerce\Administrator\Field"
/>

<field type="spacer" />

<field
name="payment_status"
type="OrderStatus"
label="PLG_J2COMMERCE_ORDER_STATUS"
description="PLG_J2COMMERCE_ORDER_STATUS_DESC"
addfieldprefix="J2Commerce\Component\J2commerce\Administrator\Field"
layout="joomla.form.field.list-fancy-select"
default="1"
/>

<field type="spacer" />

<field
name="geozone_id"
type="geozone"
label="COM_J2COMMERCE_GEOZONE_RESTRICTION"
description="COM_J2COMMERCE_GEOZONE_RESTRICTION_DESC"
addfieldprefix="J2Commerce\Component\J2commerce\Administrator\Field"
layout="joomla.form.field.list-fancy-select"
default=""
/>
<field
name="min_subtotal"
type="text"
label="COM_J2COMMERCE_PLUGIN_MIN_SUBTOTAL"
description="COM_J2COMMERCE_PLUGIN_MIN_SUBTOTAL_DESC"
default=""
/>
<field
name="max_subtotal"
type="text"
label="COM_J2COMMERCE_PLUGIN_MAX_SUBTOTAL"
description="COM_J2COMMERCE_PLUGIN_MAX_SUBTOTAL_DESC"
default=""
/>

<field type="spacer" />

<field
name="onbeforepayment"
type="textarea"
label="COM_J2COMMERCE_ON_BEFORE_PAYMENT_LABEL"
description="COM_J2COMMERCE_ON_BEFORE_PAYMENT_DESC"
cols="10"
rows="3"
default=""
/>
<field
name="onafterpayment"
type="textarea"
label="COM_J2COMMERCE_ON_AFTER_PAYMENT_LABEL"
description="COM_J2COMMERCE_ON_AFTER_PAYMENT_DESC"
cols="10"
rows="3"
default=""
/>
<field
name="onerrorpayment"
type="textarea"
label="COM_J2COMMERCE_ON_ERROR_PAYMENT_LABEL"
description="COM_J2COMMERCE_ON_ERROR_PAYMENT_DESC"
cols="10"
rows="3"
default=""
/>

<field type="spacer" />

<field
name="debug"
type="radio"
label="PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DEBUG"
description="PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DEBUG_DESC"
layout="joomla.form.field.radio.switcher"
filter="integer"
default="0"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>

</fieldset>
</fields>
</config>
</extension>

Service Provider​

<?php
// File: plugins/j2commerce/payment_mygateway/services/provider.php

declare(strict_types=1);

defined('_JEXEC') or die;

use YourCompany\Plugin\J2Commerce\PaymentMygateway\Extension\PaymentMygateway;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;

return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new PaymentMygateway(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('j2commerce', 'payment_mygateway'),
$container->get(DatabaseInterface::class)
);

$plugin->setApplication(Factory::getApplication());

return $plugin;
}
);
}
};

Extension Class (MVP)​

<?php
// File: plugins/j2commerce/payment_mygateway/src/Extension/PaymentMygateway.php

declare(strict_types=1);

namespace YourCompany\Plugin\J2Commerce\PaymentMygateway\Extension;

\defined('_JEXEC') or die;

use J2Commerce\Component\J2commerce\Administrator\Helper\OrderHistoryHelper;
use J2Commerce\Component\J2commerce\Administrator\Library\Plugins\PluginLayoutTrait;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;

final class PaymentMygateway extends CMSPlugin implements SubscriberInterface
{
use DatabaseAwareTrait;
use PluginLayoutTrait;

protected $autoloadLanguage = true;

protected $_name = 'payment_mygateway';

public function __construct(
DispatcherInterface $dispatcher,
array $config,
DatabaseInterface $db
) {
parent::__construct($dispatcher, $config);
$this->setDatabase($db);
}

public static function getSubscribedEvents(): array
{
return [
'onJ2CommerceGetPaymentPlugins' => 'onGetPaymentPlugins',
'onJ2CommercePrePayment' => 'onPrePayment',
'onJ2CommercePostPayment' => 'onPostPayment',
];
}

public function onGetPaymentPlugins(Event $event): void
{
$result = $event->getArgument('result', []);
$result[] = [
'element' => $this->_name,
'name' => Text::_($this->params->get('display_name', 'MyGateway')),
'image' => $this->params->get('display_image', ''),
];
$event->setArgument('result', $result);
}

public function onPrePayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$data = $args[1] ?? [];

if ($element !== $this->_name) {
return;
}

$vars = new \stdClass();
$vars->order_id = $data['order_id'];
$vars->orderpayment_amount = $data['orderpayment_amount'];
$vars->display_name = Text::_($this->params->get('display_name', 'MyGateway'));
$vars->onbeforepayment_text = $this->params->get('onbeforepayment', '');

$result = $event->getArgument('result', []);
$result[] = $this->resolvePluginLayout('prepayment', $vars);
$event->setArgument('result', $result);
}

public function onPostPayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$data = $args[1] ?? [];

if ($element !== $this->_name) {
return;
}

$app = Factory::getApplication();
$paction = $app->input->getString('paction');

$result = $event->getArgument('result', []);
$result[] = match ($paction) {
'process' => $this->processPayment((object) $data),
'display' => $this->resolvePluginLayout('postpayment', (object) [
'onafterpayment_text' => Text::_($this->params->get('onafterpayment', '')),
]),
default => $this->resolvePluginLayout('message', (object) [
'message' => Text::_($this->params->get('onerrorpayment', 'PLG_J2COMMERCE_PAYMENT_MYGATEWAY_ERR_PAYMENT_FAILED')),
]),
};
$event->setArgument('result', $result);
}

private function processPayment(object $data): string
{
$orderId = (int) ($data->order_id ?? Factory::getApplication()->input->getInt('order_id', 0));

// Call your gateway API here. On success, finalize the order:
$success = true; // Replace with real API call result

if (!$success) {
return $this->resolvePluginLayout('message', (object) [
'message' => Text::_('PLG_J2COMMERCE_PAYMENT_MYGATEWAY_ERR_PAYMENT_FAILED'),
]);
}

// Finalize order
$orderTable = Factory::getApplication()
->bootComponent('com_j2commerce')
->getMVCFactory()
->createTable('Order', 'Administrator');

if ($orderTable->load(['order_id' => $orderId])) {
$orderTable->transaction_id = 'YOUR_TRANSACTION_ID';
$orderTable->transaction_status = 'Completed';
$orderTable->orderpayment_type = $this->_name;
$orderTable->order_state_id = (int) $this->params->get('payment_status', 1);
$orderTable->store();

OrderHistoryHelper::add(
orderId: $orderTable->order_id,
comment: Text::sprintf('COM_J2COMMERCE_ORDER_HISTORY_PAYMENT_RECEIVED', Text::_('PLG_J2COMMERCE_PAYMENT_MYGATEWAY')),
orderStateId: (int) $orderTable->order_state_id,
);
}

Factory::getApplication()->redirect(
Route::_(
'index.php?option=com_j2commerce&view=checkout&task=checkout.confirmPayment'
. '&orderpayment_type=' . $this->_name
. '&paction=display',
false
)
);

return '';
}
}

Set the status id only — never order_state.

order_state is a deprecated denormalised copy of the status name. Core neither reads nor writes it, so anything a plugin puts there is invisible to the storefront, the admin order list and the API, all of which resolve the status through order_state_id joined to #__j2commerce_orderstatuses. The assignment does not error — DatabaseDriver skips object properties that are not real table columns — it simply does nothing.

Prefer OrderModel::updateOrderStatus() over assigning order_state_id directly. Setting the property and calling store() moves the number and nothing else. The model method also fires the status-change event third-party plugins listen for, appends the order history row, and grants downloads on the statuses configured for it. It additionally applies a compare-and-swap on the previous status, so a webhook redelivery arriving alongside an admin save cannot run those side effects twice:

Factory::getApplication()
->bootComponent('com_j2commerce')
->getMVCFactory()
->createModel('Order', 'Administrator', ['ignore_request' => true])
->updateOrderStatus((int) $orderTable->j2commerce_order_id, $newStatusId);

Assign order_state_id directly only where no transition semantics are wanted — for example when building a brand-new order row that has never had a status.


Plugin File Structure​

The full directory tree for a feature-complete payment plugin:

plugins/j2commerce/payment_mygateway/
├── payment_mygateway.xml # XML manifest (required)
├── services/
│ └── provider.php # DI container wiring (required)
├── src/
│ └── Extension/
│ └── PaymentMygateway.php # Main plugin class (required)
│ └── Helper/
│ └── MygatewayHelper.php # Static utility methods (optional)
│ └── Service/
│ ├── PaymentProcessor.php # Handles pre/post payment logic (optional)
│ ├── AdminActions.php # Void/refund/charge-card logic (optional)
│ └── ProfileManager.php # Saved payment method management (optional)
├── tmpl/
│ ├── prepayment.php # Checkout form (default subtemplate)
│ ├── postpayment.php # Post-payment screen
│ ├── message.php # Generic error/cancel message
│ ├── admin_payment_buttons.php # Void/refund buttons in order view
│ ├── admin_charge_card.php # Admin charge stored card UI
│ ├── profile_cards.php # My Account saved cards tab
│ └── bootstrap5/ # Bootstrap 5 subtemplate overrides
│ ├── prepayment.php
│ ├── postpayment.php
│ └── profile_cards.php
│ └── uikit/ # UIkit subtemplate overrides
│ └── prepayment.php
├── media/ # Installed to media/plg_j2commerce_payment_mygateway/
│ ├── css/
│ │ └── mygateway-checkout.css
│ ├── js/
│ │ ├── mygateway-checkout.js
│ │ └── mygateway-admin.js
│ └── images/
│ ├── payment_mygateway.webp
│ └── payment_mygateway_thumb.webp
├── sql/
│ ├── install.mysql.utf8.sql # Tables created on install
│ └── uninstall.mysql.utf8.sql # Tables dropped on uninstall
└── language/
└── en-GB/
├── plg_j2commerce_payment_mygateway.ini
└── plg_j2commerce_payment_mygateway.sys.ini

Key directory notes​

  • services/ — The <folder plugin="payment_mygateway">services</folder> attribute in the manifest registers this as the plugin entry point. The plugin attribute value must match the plugin element name exactly.
  • tmpl/ — Layout files. The PluginLayoutTrait::resolvePluginLayout() method searches these. Subdirectories (bootstrap5/, uikit/) are named subtemplates selectable from the plugin configuration.
  • media/ — The manifest destination attribute plg_j2commerce_payment_mygateway controls where Joomla copies these files. Assets are loaded from media/plg_j2commerce_payment_mygateway/ at runtime.
  • sql/ — Standard Joomla install/uninstall SQL. Reference as #__j2commerce_mygateway_* for any plugin-specific tables.

XML Manifest​

Configuration Field Groups​

Payment plugin manifests follow a consistent field grouping pattern. The standard sections are:

SectionPurpose
payment_method_type hidden fieldInternal marker used by the payment methods list view
Display fieldsdisplay_name, display_image — shown in checkout dropdown
API credentialsYour gateway's keys/tokens (live and optionally sandbox)
Transaction settingsAuth-capture vs auth-only, card types, etc.
subtemplate fieldLets merchants choose between Bootstrap 5, UIkit, or custom layouts
payment_statusOrder status to set on successful payment
Void/refund status fieldsOptional: change order status on void or refund
Surcharge fieldssurcharge_name, surcharge_percent, surcharge_fixed, surcharge_tax_class_id
Geo/subtotal restrictionsgeozone_id, min_subtotal, max_subtotal
articleidOptional Joomla article to display after successful payment
Custom JS hooksonselection, onbeforepayment, onafterpayment, onerrorpayment, oncancelpayment
Dashboard iconshow_dashboard_icon, dashboard_icon_label
debugEnable verbose logging

Custom J2Commerce Field Types​

These field types are registered by the J2Commerce component and available in plugin manifests:

TypeaddfieldprefixPurpose
OrderStatusJ2Commerce\Component\J2commerce\Administrator\FieldDropdown of order statuses
TaxProfileJ2Commerce\Component\J2commerce\Administrator\FieldDropdown of tax profiles
geozoneJ2Commerce\Component\J2commerce\Administrator\FieldDropdown of geo-zones
PluginSubtemplateJ2Commerce\Component\J2commerce\Administrator\FieldSubtemplate selector for the plugin

Sandbox credential pattern​

When a plugin supports both live and sandbox environments, duplicate the credential fields with a sandbox_ prefix and use showon to show the correct set:

<field name="sandbox" type="radio" layout="joomla.form.field.radio.switcher"
filter="integer" default="0">
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>

<!-- Live credentials — hidden when sandbox=1 -->
<field name="api_key" type="text" label="..." showon="sandbox:0" />

<!-- Sandbox credentials — hidden when sandbox=0 -->
<field name="sandbox_api_key" type="text" label="..." showon="sandbox:1" />

Read credentials in PHP using a helper that respects the sandbox toggle:

private function getCredential(string $key): string
{
$sandbox = (int) $this->params->get('sandbox', 0);

return $sandbox
? (string) $this->params->get('sandbox_' . $key, '')
: (string) $this->params->get($key, '');
}

Service Provider​

The service provider (services/provider.php) wires the plugin class into Joomla's DI container.

<?php
// File: plugins/j2commerce/payment_mygateway/services/provider.php

declare(strict_types=1);

defined('_JEXEC') or die;

use YourCompany\Plugin\J2Commerce\PaymentMygateway\Extension\PaymentMygateway;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;

return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new PaymentMygateway(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('j2commerce', 'payment_mygateway'),
$container->get(DatabaseInterface::class)
);

$plugin->setApplication(Factory::getApplication());

return $plugin;
}
);
}
};

If your constructor needs additional services (e.g., Joomla's Language or UserFactoryInterface), resolve them from the container here:

use Joomla\CMS\User\UserFactoryInterface;

$plugin = new PaymentMygateway(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('j2commerce', 'payment_mygateway'),
$container->get(DatabaseInterface::class),
Factory::getApplication()->getLanguage(),
$container->get(UserFactoryInterface::class)
);

Extension Class​

Class declaration​

final class PaymentMygateway extends CMSPlugin implements SubscriberInterface
{
use DatabaseAwareTrait;
use PluginLayoutTrait; // Provides resolvePluginLayout()

protected $autoloadLanguage = true;

protected $_name = 'payment_mygateway'; // Must match plugin element name

The $_name property identifies this plugin to every event. Events carry the plugin element name as their first argument; your handlers compare against $this->_name and return early if the event targets a different plugin.

getSubscribedEvents()​

public static function getSubscribedEvents(): array
{
return [
'onJ2CommerceGetPaymentPlugins' => 'onGetPaymentPlugins',
'onJ2CommerceGetPaymentOptions' => 'onGetPaymentOptions',
'onJ2CommerceCheckoutStart' => 'onCheckoutStart',
'onJ2CommerceCalculateFees' => 'onCalculateFees',
'onJ2CommercePrePayment' => 'onPrePayment',
'onJ2CommercePostPayment' => 'onPostPayment',
'onJ2CommerceRefundPayment' => 'onRefundPayment',
'onJ2CommerceProcessWebhook' => 'onProcessWebhook',
'onJ2CommerceProcessRenewalPayment' => 'onProcessRenewalPayment',
'onJ2CommerceAcceptSubscriptionPayment' => 'onAcceptSubscriptionPayment',
'onJ2CommerceAfterSubscriptionCanceled' => 'onAfterSubscriptionCanceled',
'onJ2CommerceMyProfileTab' => 'onMyProfileTab',
'onJ2CommerceMyProfileTabContent' => 'onMyProfileTabContent',
'onJ2CommerceGetQuickIcons' => 'onGetQuickIcons',
'onJ2CommerceGetDashboardMessages' => 'onGetDashboardMessages',
'onJ2CommerceBeforeAdminOrderPaymentButton' => 'onBeforeAdminOrderPaymentButton',
'onJ2CommerceAfterAdminOrderSummery' => 'onAfterAdminOrderSummery',
'onAjaxPayment_mygateway' => 'onAjaxHandler',
];
}

Only subscribe to events you implement. A minimal plugin needs only the first three.

PluginLayoutTrait​

The trait provides resolvePluginLayout(string $name, array|object $data): string. It searches layout paths in priority order:

  1. templates/{active_template}/html/plg_j2commerce_payment_mygateway/{subtemplate}/{name}.php
  2. plugins/j2commerce/payment_mygateway/tmpl/{subtemplate}/{name}.php
  3. templates/{active_template}/html/plg_j2commerce_payment_mygateway/{name}.php
  4. plugins/j2commerce/payment_mygateway/tmpl/{name}.php

The subtemplate plugin parameter (set by the merchant) selects which subdirectory is tried first.


Checkout Integration​

onJ2CommerceGetPaymentPlugins​

Fires when J2Commerce builds the list of available payment methods at checkout. Add your plugin's descriptor to the result array.

public function onGetPaymentPlugins(Event $event): void
{
$result = $event->getArgument('result', []);
$result[] = [
'element' => $this->_name,
'name' => Text::_($this->params->get('display_name', 'MyGateway')),
'image' => $this->params->get('display_image', ''),
];
$event->setArgument('result', $result);
}
KeyTypeDescription
elementstringPlugin element name — must match $this->_name
namestringDisplay label shown in the checkout payment selector
imagestringOptional media URL for a payment method logo

onJ2CommerceGetPaymentOptions​

Fires to check whether this payment method is available for the current order. Return false to hide it (e.g., outside a geo-zone or below minimum subtotal). This event passes the element name and order object as positional arguments.

public function onGetPaymentOptions(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? null;
$order = $args[1] ?? null;

if ($element !== $this->_name || $order === null) {
return;
}

$available = true;

// Example: geo-zone restriction
$geozoneId = (int) $this->params->get('geozone_id', 0);
if ($geozoneId > 0) {
$order->setAddress();
$address = $order->getBillingAddress();
$available = $this->checkGeozone($geozoneId, $address);
}

// Example: subtotal limits
if ($available) {
$min = (float) $this->params->get('min_subtotal', 0);
$max = (float) $this->params->get('max_subtotal', 0);

if ($min > 0 && $order->order_subtotal < $min) {
$available = false;
}

if ($max > 0 && $order->order_subtotal > $max) {
$available = false;
}
}

$event->setArgument('result', $available);
}

onJ2CommerceCheckoutStart​

Fires once when the checkout page is initialized — use it to load your plugin's CSS and JavaScript assets. Assets registered here are available for the entire checkout session.

public function onCheckoutStart(Event $event): void
{
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();

$wa->registerAndUseScript(
'plg_j2commerce_payment_mygateway.checkout',
'media/plg_j2commerce_payment_mygateway/js/mygateway-checkout.js',
[],
['defer' => true]
);
$wa->registerAndUseStyle(
'plg_j2commerce_payment_mygateway.css',
'media/plg_j2commerce_payment_mygateway/css/mygateway-checkout.css'
);
}

Use the asset name pattern plg_j2commerce_{element}.{descriptor} so the Web Asset Manager deduplicates if the same asset is registered elsewhere.

onJ2CommercePrePayment​

Fires when the customer selects your payment method and the checkout renders the payment form. Return an HTML string — typically rendered through resolvePluginLayout().

public function onPrePayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$data = $args[1] ?? [];

if ($element !== $this->_name) {
return;
}

$vars = new \stdClass();
$vars->order_id = $data['order_id'];
$vars->orderpayment_amount = $data['orderpayment_amount'];
$vars->orderpayment_type = $this->_name;
$vars->display_name = Text::_($this->params->get('display_name', 'MyGateway'));
$vars->display_image = $this->params->get('display_image', '');
$vars->onbeforepayment_text = $this->params->get('onbeforepayment', '');
$vars->csrf_token = \Joomla\CMS\Session\Session::getFormToken();

$result = $event->getArgument('result', []);
$result[] = $this->resolvePluginLayout('prepayment', $vars);
$event->setArgument('result', $result);
}

The $data array contains at minimum: order_id, orderpayment_amount, orderpayment_type.

onJ2CommercePostPayment​

Fires when the payment form is submitted. The paction input parameter controls which sub-action to execute:

paction valueMeaning
processCustomer submitted the payment form — process the transaction
displayPayment was already processed — show the confirmation screen
cancelCustomer cancelled — show cancellation message
(anything else)Show generic error
public function onPostPayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$data = $args[1] ?? [];

if ($element !== $this->_name) {
return;
}

$paction = Factory::getApplication()->input->getString('paction');

$result = $event->getArgument('result', []);
$result[] = match ($paction) {
'process' => $this->processPayment((object) $data),
'display' => $this->resolvePluginLayout('postpayment', (object) [
'onafterpayment_text' => Text::_($this->params->get('onafterpayment', '')),
]),
'cancel' => $this->resolvePluginLayout('message', (object) [
'message' => Text::_($this->params->get('oncancelpayment', '')),
]),
default => $this->resolvePluginLayout('message', (object) [
'message' => Text::_($this->params->get('onerrorpayment', 'PLG_J2COMMERCE_PAYMENT_MYGATEWAY_ERR_PAYMENT_FAILED')),
]),
};
$event->setArgument('result', $result);
}

The value this match returns is rendered markup only — CheckoutController::confirmPayment() decides whether the payment finalized by reading the order row your plugin writes, not by inspecting this event's result (see Finalizing the order on success).

Full payment flow​

Finalizing the order on success​

After a successful API response, update the order table and add a history entry:

private function finalizeOrder(int $orderId, string $transactionId): void
{
$orderTable = Factory::getApplication()
->bootComponent('com_j2commerce')
->getMVCFactory()
->createTable('Order', 'Administrator');

if (!$orderTable->load(['order_id' => $orderId])) {
return;
}

$orderTable->transaction_id = $transactionId;
$orderTable->transaction_status = 'Completed';
$orderTable->orderpayment_type = $this->_name;
$orderTable->order_state_id = (int) $this->params->get('payment_status', 1);
$orderTable->transaction_details = json_encode([
'transId' => $transactionId,
// Include any gateway-specific data here
]);
$orderTable->store();

OrderHistoryHelper::add(
orderId: $orderTable->order_id,
comment: Text::sprintf(
'COM_J2COMMERCE_ORDER_HISTORY_PAYMENT_RECEIVED',
Text::_('PLG_J2COMMERCE_PAYMENT_MYGATEWAY')
),
orderStateId: (int) $orderTable->order_state_id,
);
}

Then redirect to the confirmation step:

Factory::getApplication()->redirect(
Route::_(
'index.php?option=com_j2commerce&view=checkout&task=checkout.confirmPayment'
. '&orderpayment_type=' . $this->_name
. '&paction=display',
false
)
);

Why this matters: the write above is the success signal​

Since PR #2537 (fixing #2532), CheckoutController::confirmPayment() decides whether to dispatch onJ2CommerceAfterPayment and send the order confirmation emails by re-reading the order row after your onJ2CommercePostPayment handler returns — not by inspecting what that handler returned. Returning postpayment.php markup is what the shopper sees; on its own it has no effect on whether the order is treated as finalized.

A plugin states that it finalized by writing one of the following to the order row:

  • a new order_state_id — any value that differs from the one the order was parked at when checkout began processing the payment, whose lifecycle type is neither failed nor cancelled;
  • transaction_status of Completed or authorized (case-insensitive) — the pattern finalizeOrder() above already follows;
  • a re-primed j2commerce.order_id in user state, for a gateway that finalizes a different order than the session was primed with — an off-site return replacing a stale, abandoned order (#1208).

Before that fix, returning success markup from onJ2CommercePostPayment was on its own enough to trigger AfterPayment and the confirmation emails — a clause in the predicate was true for every POST. That clause was the underlying defect: it was equally true when a plugin returned failure markup, so a declined card could still produce a confirmation email. It no longer exists. A plugin that finalizes a charge without writing anything to the order row now gets neither AfterPayment nor an order email, silently. The fix for such a plugin is to record its outcome on the order row, not to look for a substitute signal in the event result — a plugin that writes nothing is equally unable to have its failures told apart from its successes by anything that reads the row, which is the same ambiguity described above.

External gateway redirects​

For gateways that redirect the customer to an external payment page (e.g., PayPal Standard, bank redirect), return a redirect URL from processPayment() instead of rendering a form:

private function processPayment(object $data): string
{
$orderId = (int) ($data->order_id ?? 0);
$returnUrl = Route::_('index.php?option=com_j2commerce&view=checkout'
. '&task=checkout.confirmPayment&orderpayment_type=' . $this->_name
. '&paction=process', false, Route::TLS_IGNORE, true);

$gatewayUrl = 'https://pay.mygateway.com/checkout?'
. 'amount=' . $data->orderpayment_amount
. '&order_id=' . $orderId
. '&return=' . urlencode($returnUrl);

Factory::getApplication()->redirect($gatewayUrl);

return '';
}

The gateway redirects back to paction=process after payment, where your plugin receives the callback and finalizes the order.


Payment Processing​

Loading order context​

private function loadOrder(int $orderId): ?object
{
$orderTable = Factory::getApplication()
->bootComponent('com_j2commerce')
->getMVCFactory()
->createTable('Order', 'Administrator');

return $orderTable->load(['order_id' => $orderId]) ? $orderTable : null;
}

Storing transaction details​

The transaction_details column stores gateway-specific data as a JSON string. Include enough information to support later void/refund operations:

$orderTable->transaction_details = json_encode([
'transId' => $transactionId,
'authCode' => $authCode,
'accountType' => 'Visa',
'accountNumber' => 'XXXX1234',
// Do NOT store raw card numbers or CVV values — ever
]);

onJ2CommerceCalculateFees​

Fires to add a payment surcharge to the order total. The event passes the element name and order object.

public function onCalculateFees(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? null;
$order = $args[1] ?? null;

if ($element !== $this->_name || $order === null) {
return;
}

if (($order->orderpayment_type ?? '') !== $this->_name) {
return;
}

$surchargePercent = (float) $this->params->get('surcharge_percent', 0);
$surchargeFixed = (float) $this->params->get('surcharge_fixed', 0);

if ($surchargePercent <= 0 && $surchargeFixed <= 0) {
return;
}

$total = $order->order_subtotal + $order->order_shipping + $order->order_shipping_tax;
$surcharge = ($total * $surchargePercent / 100) + $surchargeFixed;

if ($surcharge <= 0) {
return;
}

$name = $this->params->get('surcharge_name', 'Payment surcharge');
$taxClassId = $this->params->get('surcharge_tax_class_id', '');
$taxable = !empty($taxClassId) && (int) $taxClassId > 0;

$order->add_fee($name, round($surcharge, 2), $taxable, $taxClassId);
}

Subscriptions and Recurring Payments​

If your plugin supports recurring billing, implement these two events.

onJ2CommerceAcceptSubscriptionPayment​

Fired to ask whether your plugin can handle subscription payments. Return true if yes.

public function onAcceptSubscriptionPayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? null;

if ($element !== $this->_name) {
return;
}

$event->setArgument('result', true);
}

onJ2CommerceProcessRenewalPayment​

Fired by the subscription system when a renewal payment is due. The event passes the element name, subscription object, and new order object.

public function onProcessRenewalPayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$subscription = $args[1] ?? null;
$order = $args[2] ?? null;

if ($element !== $this->_name || $subscription === null || $order === null) {
return;
}

// Retrieve the stored payment profile for this subscription
// Charge the stored card
// On success:
$event->setArgument('result', [
'success' => true,
'transaction_id' => 'TXN_ID_FROM_GATEWAY',
]);

// On failure:
// $event->setArgument('result', ['success' => false, 'error' => 'Declined']);
}

onJ2CommerceAfterSubscriptionCanceled​

Fired when a subscription is cancelled. Use this to clean up any gateway-side recurring billing profiles.

public function onAfterSubscriptionCanceled(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';

if ($element !== $this->_name) {
return;
}

// Cancel recurring profile at the gateway if applicable
$event->setArgument('result', ['status' => 'subscription_cancellation_registered']);
}

Subscription metadata storage​

Store the mapping between a subscription and its gateway profile in #__j2commerce_metafields:

// Write
$row = new \stdClass();
$row->owner_resource = 'subscriptions';
$row->owner_id = $subscriptionId;
$row->namespace = 'subscription';
$row->metakey = 'mygateway_profile_id';
$row->metavalue = $gatewayCustomerProfileId;
$db->insertObject('#__j2commerce_metafields', $row);

// Read
$query = $db->getQuery(true)
->select([$db->quoteName('metakey'), $db->quoteName('metavalue')])
->from($db->quoteName('#__j2commerce_metafields'))
->where($db->quoteName('owner_resource') . ' = ' . $db->quote('subscriptions'))
->where($db->quoteName('owner_id') . ' = :subId')
->where($db->quoteName('namespace') . ' = ' . $db->quote('subscription'))
->bind(':subId', $subscriptionId, ParameterType::INTEGER);

Save Card / Stored Payments​

Stored payment methods let customers save a payment profile at the gateway and reuse it for future purchases or subscription renewals without re-entering card details.

Database table​

Create a table to store the link between Joomla user IDs and gateway customer profile IDs:

-- sql/install.mysql.utf8.sql
CREATE TABLE IF NOT EXISTS `#__j2commerce_paymentprofiles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`provider` VARCHAR(50) NOT NULL DEFAULT 'mygateway',
`customer_profile_id` VARCHAR(50) NOT NULL,
`environment` VARCHAR(10) NOT NULL DEFAULT 'production',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_provider_env` (`user_id`, `provider`, `environment`),
KEY `idx_customer_profile` (`customer_profile_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;

AJAX handler for card management​

Customer-facing card operations (add, delete, update) are handled through a Joomla AJAX plugin event named onAjax{ElementName}. For payment_mygateway this is onAjaxPayment_mygateway.

Subscribe to it and dispatch by task:

'onAjaxPayment_mygateway' => 'onAjaxHandler',
public function onAjaxHandler(Event $event): void
{
$app = Factory::getApplication();
$user = $app->getIdentity();

if (!$user || $user->guest) {
$event->setArgument('result', [['success' => false, 'error' => 'Not authenticated']]);
return;
}

if (!\Joomla\CMS\Session\Session::checkToken('request')) {
$event->setArgument('result', [['success' => false, 'error' => 'Invalid token']]);
return;
}

$task = $app->input->getCmd('task', '');
$result = match ($task) {
'addCard' => $this->addCard($user->id, $app->input),
'deleteCard' => $this->deleteCard($user->id, $app->input->getString('profile_id', '')),
default => ['success' => false, 'error' => 'Unknown task'],
};

$event->setArgument('result', [$result]);
}

AJAX endpoint URL: index.php?option=com_ajax&plugin=payment_mygateway&group=j2commerce&task=addCard&format=json

Always pass the CSRF token: &{token}=1 appended to the URL, or in the POST body.

Admin charge stored card​

onJ2CommerceAfterAdminOrderSummery fires in the order view after the summary block. Return HTML to inject a UI that lets administrators charge a stored card against an unpaid order.

public function onAfterAdminOrderSummery(Event $event): void
{
$args = $event->getArguments();
$item = $args[0] ?? null;
$userId = (int) ($item->user_id ?? 0);

if (!$item || ($item->orderpayment_type ?? '') !== $this->_name || $userId < 1) {
return;
}

// Load stored cards for this user, render the charge UI
$cards = $this->getStoredCards($userId);

if (empty($cards)) {
return;
}

$vars = new \stdClass();
$vars->order_id = $item->order_id ?? $item->j2commerce_order_id ?? 0;
$vars->order_total = (float) ($item->order_total ?? 0);
$vars->cards = $cards;
$vars->csrf_token = \Joomla\CMS\Session\Session::getFormToken();

$result = $event->getArgument('result', []);
$result[] = $this->resolvePluginLayout('admin_charge_card', $vars);
$event->setArgument('result', $result);
}

Admin charge operations should verify the user has core.edit on com_j2commerce:

$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.edit', 'com_j2commerce')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}

Void and Refund​

onJ2CommerceRefundPayment​

Fired by the admin order view Refund button. The event passes element name, order ID, and optional partial refund amount (null = full refund).

public function onRefundPayment(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';
$orderId = (int) ($args[1] ?? 0);
$amount = $args[2] !== null ? (float) $args[2] : null;

if ($element !== $this->_name) {
return;
}

try {
$result = $this->processRefund($orderId, $amount);
$event->setArgument('result', $result);
} catch (\Exception $e) {
$event->setArgument('result', ['success' => false, 'error' => $e->getMessage()]);
}
}

After a successful refund, update the order:

$orderTable->transaction_status = 'Refunded';
$orderTable->order_refund = $existingRefunds + $refundAmount;

if ((int) $this->params->get('refund_change_status', 0)) {
$refundStatusId = (int) $this->params->get('refund_order_status', 0);
if ($refundStatusId > 0) {
$orderTable->order_state_id = $refundStatusId;
}
}

$orderTable->store();

OrderHistoryHelper::add(
orderId: $orderTable->order_id,
comment: 'Refund processed — ' . number_format($refundAmount, 2),
orderStateId: (int) $orderTable->order_state_id,
);

Void (AJAX)​

The void operation is typically implemented as an AJAX task within onAjaxHandler (or via onJ2CommerceBeforeAdminOrderPaymentButton buttons) rather than a separate event. The void/refund UI buttons are injected by onJ2CommerceBeforeAdminOrderPaymentButton:

public function onBeforeAdminOrderPaymentButton(Event $event): void
{
$args = $event->getArguments();
$item = $args[0] ?? null;

if (!$item || ($item->orderpayment_type ?? '') !== $this->_name) {
return;
}

$vars = new \stdClass();
$vars->order_id = $item->order_id ?? $item->j2commerce_order_id ?? 0;
$vars->can_void = true; // Check transaction status from gateway
$vars->can_refund = true; // Check if settled
$vars->csrf_token = \Joomla\CMS\Session\Session::getFormToken();

$result = $event->getArgument('result', []);
$result[] = $this->resolvePluginLayout('admin_payment_buttons', $vars);
$event->setArgument('result', $result);

// Load admin JS for the button event handlers
Factory::getApplication()->getDocument()->getWebAssetManager()
->registerAndUseScript(
'plg_j2commerce_payment_mygateway.admin',
'media/plg_j2commerce_payment_mygateway/js/mygateway-admin.js',
[],
['defer' => true]
);
}

My Account — My Payments​

onJ2CommerceMyProfileTab​

Fires when building the tab navigation on the My Account page. Return the <li> nav item HTML.

public function onMyProfileTab(Event $event): void
{
if (!(int) $this->params->get('allow_saved_cards', 1)) {
return;
}

$user = Factory::getApplication()->getIdentity();

if (!$user || $user->guest) {
return;
}

$result = $event->getArgument('result', []);
$result[] = '<li class="nav-item" role="presentation">'
. '<button class="nav-link" id="mygateway-cards-tab" data-bs-toggle="tab"'
. ' data-bs-target="#mygateway-cards-pane" type="button" role="tab"'
. ' aria-controls="mygateway-cards-pane" aria-selected="false">'
. '<span class="fa-solid fa-credit-card me-1"></span>'
. htmlspecialchars(Text::_('PLG_J2COMMERCE_PAYMENT_MYGATEWAY_PROFILE_TAB'), ENT_QUOTES, 'UTF-8')
. '</button></li>';
$event->setArgument('result', $result);
}

onJ2CommerceMyProfileTabContent​

Fires when building the tab pane content. Return the <div class="tab-pane ..."> HTML wrapping your layout.

public function onMyProfileTabContent(Event $event): void
{
if (!(int) $this->params->get('allow_saved_cards', 1)) {
return;
}

$user = Factory::getApplication()->getIdentity();

if (!$user || $user->guest) {
return;
}

$vars = new \stdClass();
$vars->cards = $this->getStoredCards($user->id);
$vars->csrf_token = \Joomla\CMS\Session\Session::getFormToken();

$content = $this->resolvePluginLayout('profile_cards', $vars);

$result = $event->getArgument('result', []);
$result[] = '<div class="tab-pane fade" id="mygateway-cards-pane" role="tabpanel"'
. ' aria-labelledby="mygateway-cards-tab">' . $content . '</div>';
$event->setArgument('result', $result);
}

Layout Overrides​

Default layout resolution​

PluginLayoutTrait::resolvePluginLayout() uses Joomla's FileLayout class. This means the standard Joomla template override mechanism applies. Merchants can override any plugin layout by creating a file at:

templates/{template_name}/html/plg_j2commerce_payment_mygateway/{layout_name}.php

For example, to override the payment form:

templates/cassiopeia/html/plg_j2commerce_payment_mygateway/prepayment.php

Subtemplate directories​

The subtemplate plugin parameter selects a named subdirectory. Ship your plugin with subdirectory layouts for each supported template framework:

tmpl/
├── prepayment.php ← default (no subtemplate)
└── bootstrap5/
└── prepayment.php ← used when subtemplate = "bootstrap5"
└── uikit/
└── prepayment.php ← used when subtemplate = "uikit"

Template override for a specific subtemplate:

templates/cassiopeia/html/plg_j2commerce_payment_mygateway/bootstrap5/prepayment.php

Layout file conventions​

Layout files receive $displayData as their context. Always access variables through $displayData:

<?php
// File: plugins/j2commerce/payment_mygateway/tmpl/prepayment.php

declare(strict_types=1);
defined('_JEXEC') or die;

use Joomla\CMS\Language\Text;

$orderId = $displayData['order_id'] ?? 0;
$amount = $displayData['orderpayment_amount'] ?? '0.00';
$csrfToken = $displayData['csrf_token'] ?? '';
$beforeText = $displayData['onbeforepayment_text'] ?? '';
?>

<form class="j2commerce-mygateway-payment" method="post"
data-csrf-token="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>"
data-order-id="<?php echo (int) $orderId; ?>">

<input type="hidden" name="orderpayment_type" value="payment_mygateway">

<?php if (!empty($beforeText)) : ?>
<div class="mb-3"><?php echo htmlspecialchars($beforeText, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>

<!-- Your payment form fields -->

<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
<?php echo Text::_('COM_J2COMMERCE_PLACE_ORDER'); ?>
</button>
</form>

Always htmlspecialchars() every output variable. Never echo raw input data.


Language Strings​

Naming convention​

All language strings for payment plugins follow this pattern:

PLG_J2COMMERCE_PAYMENT_{NAME}_*

Where {NAME} is the uppercase plugin element suffix (e.g., MYGATEWAY for payment_mygateway).

Required files​

  • language/en-GB/plg_j2commerce_payment_mygateway.ini — All strings except the plugin title/description
  • language/en-GB/plg_j2commerce_payment_mygateway.sys.ini — Plugin title and description (used by Joomla extension manager)

sys.ini minimum​

PLG_J2COMMERCE_PAYMENT_MYGATEWAY="MyGateway Payment"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DESC="Accept payments via MyGateway"
; Plugin label
PLG_J2COMMERCE_PAYMENT_MYGATEWAY="MyGateway Payment"

; Configuration
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_API_KEY="API Key"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_API_KEY_DESC="Your MyGateway API key from the merchant dashboard"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_SANDBOX="Use Sandbox"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_SANDBOX_DESC="Enable test mode. No real payments are processed."
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DEBUG="Debug Mode"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_DEBUG_DESC="Enable detailed logging to administrator/logs/payment_mygateway.php"

; Checkout
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_PROCESSING="Processing your payment..."
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_COMPLETED="Payment completed successfully"

; Errors
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_ERR_PAYMENT_FAILED="Payment processing failed. Please try again."
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_ERR_INVALID_ORDER="Invalid or expired order"

; Admin
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_REFUND="Refund"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_VOID="Void"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_REFUND_SUCCESS="Refund processed successfully"
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_VOID_SUCCESS="Transaction voided successfully"

; Sandbox warning (dashboard message)
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_SANDBOX_WARNING="MyGateway is in Sandbox mode — no real payments will be processed"

; My Account tab
PLG_J2COMMERCE_PAYMENT_MYGATEWAY_PROFILE_TAB="Payment Methods"

Shared J2Commerce strings​

These strings are defined by the J2Commerce component and do not need to be duplicated in your plugin:

KeyUsed for
COM_J2COMMERCE_PLACE_ORDERSubmit button label on checkout
COM_J2COMMERCE_CART_RETURN"Return to cart" link label
COM_J2COMMERCE_ORDER_HISTORY_PAYMENT_RECEIVEDOrder history entry (sprintf, gateway name as arg)
COM_J2COMMERCE_PLUGIN_DISPLAY_NAMELabel for display name field
COM_J2COMMERCE_PLUGIN_DISPLAY_IMAGELabel for display image field
COM_J2COMMERCE_PLUGIN_SUBTEMPLATELabel for subtemplate selector
COM_J2COMMERCE_PLUGIN_SURCHARGE_NAMELabel for surcharge name field
PLG_J2COMMERCE_ORDER_STATUSLabel for payment status field
PLG_J2COMMERCE_ORDER_STATUS_DESCDescription for payment status field
PLG_J2COMMERCE_PAYMENT_METHODHidden field default value

Security Best Practices​

CSRF tokens​

Every form submission that modifies data — payment form submission and AJAX requests — must include and verify a CSRF token.

In the layout template, embed the token:

<input type="hidden" name="<?php echo \Joomla\CMS\Session\Session::getFormToken(); ?>" value="1">

Or for AJAX, pass it as a data attribute on the form and read it in JavaScript:

data-csrf-token="<?php echo htmlspecialchars(\Joomla\CMS\Session\Session::getFormToken(), ENT_QUOTES, 'UTF-8'); ?>"

Verify in your AJAX handler before processing any task:

if (!\Joomla\CMS\Session\Session::checkToken('request')) {
$event->setArgument('result', [['success' => false, 'error' => 'Invalid token']]);
return;
}

Input validation and sanitization​

Always retrieve input through Joomla's Input class with the appropriate filter:

$app    = Factory::getApplication();
$amount = (float) $app->input->getString('amount', '0'); // Cast after retrieval
$orderId = $app->input->getInt('order_id', 0); // Use typed getter
$token = $app->input->getString('token', ''); // getString for arbitrary strings

Never echo unfiltered POST or GET values. Always htmlspecialchars() before output.

PCI compliance​

  • Never log card numbers, CVV codes, or expiration dates. This applies to every log statement, debug output, and database column.
  • Never transmit raw card data through your server. Use the gateway's JavaScript tokenization library (equivalent to Authorize.Net's Accept.js) to convert card data to an opaque token before the form is submitted to your server.
  • Store only tokens and last-four digits in your database. The full card number must never reach your PHP code.
  • HTTPS is required. Never render a payment form over HTTP.
  • Validate webhook signatures if your gateway sends webhooks. Always verify the cryptographic signature before processing the payload.

Debug logging​

Use Joomla's Log class rather than error_log() or var_dump():

use Joomla\CMS\Log\Log;

// Add a log category in your constructor or first call:
Log::addLogger(
['text_file' => 'payment_mygateway.php'],
Log::ALL,
['plg_j2commerce_payment_mygateway']
);

// Log an event:
Log::add('Payment failed for order #' . $orderId . ': ' . $errorMessage, Log::ERROR, 'plg_j2commerce_payment_mygateway');

Guard verbose logging behind the debug parameter:

if ((bool) (int) $this->params->get('debug', 0)) {
Log::add('API request: ' . $endpoint, Log::DEBUG, 'plg_j2commerce_payment_mygateway');
}

Log files are written to administrator/logs/payment_mygateway.php.


Webhook Support​

onJ2CommerceProcessWebhook​

Fired when J2Commerce receives an inbound webhook notification. The event passes the element name as argument 0.

public function onProcessWebhook(Event $event): void
{
$args = $event->getArguments();
$element = $args[0] ?? '';

if ($element !== $this->_name) {
return;
}

$rawBody = file_get_contents('php://input');

if ($rawBody === false || $rawBody === '') {
$event->setArgument('result', ['status' => 400, 'message' => 'No body']);
return;
}

// Verify signature before processing
$signature = Factory::getApplication()->input->server->getString('HTTP_X_GATEWAY_SIGNATURE', '');

if (!$this->verifyWebhookSignature($rawBody, $signature)) {
$event->setArgument('result', ['status' => 401, 'message' => 'Invalid signature']);
return;
}

try {
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$result = $this->handleWebhookPayload($payload);
$event->setArgument('result', $result);
} catch (\Exception $e) {
$event->setArgument('result', ['status' => 500, 'message' => 'Internal error']);
}
}

private function verifyWebhookSignature(string $body, string $receivedSignature): bool
{
$secret = $this->getCredential('webhook_secret');
$expectedSignature = hash_hmac('sha256', $body, $secret);
return hash_equals($expectedSignature, $receivedSignature);
}

Return an array with a status key (HTTP status code) and optional message.


Testing and Debugging​

Install and enable​

php cli/joomla.php extension:discover

After installing the plugin zip, enable it at J2Commerce -> Payments -> Payment Methods.

Sandbox mode​

All payment plugins should include a sandbox parameter. When enabled, the plugin connects to the gateway's test environment. Always test the full payment flow in sandbox mode before going live:

  1. Enable sandbox in the plugin configuration.
  2. Place a test order.
  3. Use the gateway's test card numbers to simulate success, decline, and error scenarios.
  4. Verify the order status changes correctly.
  5. Test the void and refund flows from the admin order view.

Debug log​

Enable the debug parameter in the plugin configuration. Check administrator/logs/payment_mygateway.php for API request/response details.

Common issues​

Plugin not appearing in checkout

  • Verify the plugin is enabled in J2Commerce -> Payments.
  • Check that onGetPaymentPlugins always calls $event->setArgument('result', $result).
  • Confirm $this->_name matches the plugin element name exactly.

Payment form not rendering

  • Confirm onPrePayment appends to $event->getArgument('result', []) and calls setArgument — do not return the HTML directly from the method.
  • Verify the layout file path. Check plugins/j2commerce/payment_mygateway/tmpl/prepayment.php exists.

Order not updating after payment

  • Ensure $orderTable->store() is called after setting transaction_id, order_state_id, etc.
  • Setting order_state instead of order_state_id silently does nothing — the column is deprecated and unread. Use order_state_id, and prefer OrderModel::updateOrderStatus() so the status-change event fires and order history is written.
  • Check that the OrderHistoryHelper::add() call receives a non-empty orderId.

AJAX task returning 403

  • Pass the CSRF token: append &{getFormToken}=1 or include it in the POST body.
  • Confirm Session::checkToken('request') is called with 'request' (not the default 'post'), as AJAX requests use GET or mixed methods.

Assets not loading

  • Use registerAndUseScript / registerAndUseStyle, not useScript / useStyle. The useScript form requires a pre-registered joomla.asset.json entry and fails silently if the asset is not registered.
  • Asset names must be globally unique. Use plg_j2commerce_{element}.{descriptor} to avoid collisions.

CLI and MCP Tools​

Scaffold a new payment plugin​

The J2Commerce MCP server includes a j2c_scaffold_plugin tool that generates a complete payment plugin boilerplate from a single command. It creates all required files with correct namespaces, language keys, and event stubs pre-filled.

Refer to the companion MCP server documentation (.documentation/developer6/tools/mcp-server.md) for connection details and full tool reference.

CLI commands​

# Clear Joomla's extension cache after code changes
php cli/joomla.php cache:clear

# Discover newly placed extension files
php cli/joomla.php extension:discover

# Check syntax of a specific PHP file
php -l plugins/j2commerce/payment_mygateway/src/Extension/PaymentMygateway.php

Events Reference​

EventArgumentsRequiredPurpose
onJ2CommerceGetPaymentPlugins—YesRegister plugin in checkout dropdown
onJ2CommerceGetPaymentOptionselement, orderNoEligibility check (geo-zone, subtotal)
onJ2CommerceCheckoutStart—NoLoad checkout JS/CSS assets
onJ2CommerceCalculateFeeselement, orderNoAdd payment surcharge to order total
onJ2CommercePrePaymentelement, data[]YesRender checkout payment form
onJ2CommercePostPaymentelement, data[]YesProcess payment and finalize order
onJ2CommerceRefundPaymentelement, orderId, amount|nullNoProcess refund from admin order view
onJ2CommerceProcessWebhookelementNoHandle inbound webhook from gateway
onJ2CommerceAcceptSubscriptionPaymentelementNoDeclare subscription support
onJ2CommerceProcessRenewalPaymentelement, subscription, orderNoCharge renewal for a subscription
onJ2CommerceAfterSubscriptionCanceledelementNoClean up on subscription cancellation
onJ2CommerceMyProfileTab—NoAdd tab to My Account page
onJ2CommerceMyProfileTabContent—NoAdd tab content to My Account page
onJ2CommerceGetQuickIcons—NoAdd icon to J2Commerce dashboard
onJ2CommerceGetDashboardMessages—NoAdd warning/info messages to dashboard
onJ2CommerceBeforeAdminOrderPaymentButtonitemNoInject void/refund buttons in order view
onJ2CommerceAfterAdminOrderSummeryitemNoInject charge-card UI in order view
onAjaxPayment_{element}—NoAJAX endpoint for JS-driven tasks

All event handlers must call $event->setArgument('result', ...) to pass data back — return values from handler methods are silently discarded.