Payment plugin development
Payment plugins live in app/Pay/<Name>/. The directory name is the handle, which is how the whole system identifies this payment method.
Directory layout
app/Pay/Demo/
Config/
Info.php Metadata plus callback rules (required)
Config.php Default configuration values
Submit.js Form shown under "Payment interfaces" (legacy Submit.php also works)
Impl/
Pay.php Order creation (required)
Signature.php Callback signature verification (required when signing is on)
View/
1.html Template used when rendering the payment page locally
Assets/ The plugin's own static assets
Vendor/
autoload.php The plugin's private third-party dependencies (optional)Config/Info.php
Two sections more than an ordinary plugin: options and callback.
<?php
declare(strict_types=1);
return [
'version' => '1.0.0',
'name' => 'Demo Pay',
'author' => 'Your name',
'website' => 'https://example.com',
'description' => 'One line of description',
// Which payment modes this plugin offers; the site owner picks one
'options' => [
1 => 'QR code',
2 => 'Desktop',
3 => 'Mobile web',
],
// How callbacks are validated — the core follows this definition automatically
'callback' => [
\App\Consts\Pay::IS_SIGN => true, // verify the signature?
\App\Consts\Pay::IS_STATUS => true, // check a status field?
\App\Consts\Pay::FIELD_STATUS_KEY => 'trade_status', // name of the status field
\App\Consts\Pay::FIELD_STATUS_VALUE => 'TRADE_SUCCESS', // value that means success
\App\Consts\Pay::FIELD_ORDER_KEY => 'out_trade_no', // name of the order-number field
\App\Consts\Pay::FIELD_AMOUNT_KEY => 'total_amount', // name of the amount field
\App\Consts\Pay::FIELD_RESPONSE => 'success' // what to echo back to the gateway
]
];Without the
callbacksection, callbacks are rejected outright with the reason "plugin is missing the callback definition in Config/Info.php".
Config/Submit.js — the settings form
The fields the site owner fills in under Payment interfaces are defined here. The format is identical to ordinary plugins — see plugin development · the settings form.
Payment plugins usually split into tabs: general settings plus one per payment mode.
[
{
name: `${util.icon("/app/Pay/Demo/Assets/Icon/Setting.png")} General`,
form: [
{ title: "Merchant ID", name: "mch_id", type: "input", placeholder: "The merchant ID issued by the provider", required: true },
{ title: "API key", name: "key", type: "input", placeholder: "The key issued by the provider", required: true }
]
},
{
name: "QR code",
form: [
{ title: "Payee", name: "payee", type: "input", placeholder: "Payee shown at checkout" }
]
}
]A group's
nameacceptsutil.icon(), which takes either a Font Awesome class or a path to an image in the plugin's ownAssets/.
The legacy Config/Submit.php (a flat array of fields) still works; when both files exist, Submit.js takes precedence.
Impl/Pay.php
Implements the App\Pay\Pay interface, which has a single method, trade():
<?php
declare(strict_types=1);
namespace App\Pay\Demo\Impl;
use App\Entity\PayEntity;
use App\Pay\Base;
use Kernel\Exception\JSONException;
class Pay extends Base implements \App\Pay\Pay
{
public function trade(): PayEntity
{
// $this->code is the options key the site owner selected
if ($this->code == 1) {
return $this->qrcode();
}
throw new JSONException("Invalid request");
}
private function qrcode(): PayEntity
{
// Call the provider using the credentials in $this->config to obtain a payment URL
$payUrl = '...';
$entity = new PayEntity();
$entity->setType(\App\Pay\Pay::TYPE_REDIRECT);
$entity->setUrl($payUrl);
return $entity;
}
}What the base class gives you
Extending App\Pay\Base provides:
| Property | Meaning |
|---|---|
$this->amount | Order amount (float) |
$this->tradeNo | Order number |
$this->config | The configuration the site owner entered |
$this->callbackUrl | Async callback URL to hand to the provider |
$this->returnUrl | Where the buyer returns after paying |
$this->clientIp | Buyer's IP |
$this->code | The payment mode chosen by the site owner (an options key) |
$this->handle | The plugin directory name |
Three ways to present payment
PayEntity::setType() decides how the storefront gets the buyer to the payment page:
| Constant | Value | Behaviour |
|---|---|---|
TYPE_REDIRECT | 2 | Redirect straight to the URL from setUrl() |
TYPE_LOCAL_RENDER | 3 | Render the payment page locally from the plugin's View/<code>.html — for drawing your own QR code, say |
TYPE_SUBMIT | 4 | Submit a POST form to the provider |
Impl/Signature.php
Required whenever IS_SIGN is true. Implement verification():
<?php
declare(strict_types=1);
namespace App\Pay\Demo\Impl;
class Signature
{
public function verification(array $map, array $config): bool
{
$sign = $map['sign'] ?? '';
unset($map['sign']);
ksort($map);
$expect = md5(urldecode(http_build_query($map) . '&key=' . $config['app_key']));
return hash_equals($expect, (string)$sign);
}
}Returning false makes the core reject the callback and fire the SERVICE_PAY_CALLBACK_FAIL hook.
How the core handles a callback
When a callback arrives, the core does the following in order. Everything must pass before the order is delivered:
- Read the
callbackdefinition fromConfig/Info.php— reject if absent (plugin) - Load
Vendor/autoload.phpif the plugin has one - If
IS_SIGN— check that credentials are configured (reject withcredentialif not), then callSignature::verification()(reject withsignon failure) - If
IS_STATUS— compare the status field (reject withstatuson mismatch) - Extract the order number and amount and hand them to the order service
- Echo the
FIELD_RESPONSEvalue back to the gateway
Every failure fires the SERVICE_PAY_CALLBACK_FAIL hook (0x3010) with its $reason — see the hook reference. Subscribe to it to build payment alerting.
Amounts and currency
When the site is priced in a currency other than CNY, the amount submitted to the gateway must be converted. The exchange rate means "how much CNY one unit of the site currency is worth".
When displaying amounts on the storefront, never hard-code ¥ or "yuan". Use:
\App\Util\Currency::symbol()Hard-coded currency symbols are the single most common display bug in payment plugins — the amount charged is correct, but the currency shown is not.
Private dependencies
If your plugin needs a third-party SDK, put it in the plugin's own Vendor/ with an autoload.php; the core requires it automatically before handling a callback. Do not modify the global composer.json.
Testing
Payment interfaces in the admin panel includes a callback test (callbackTest) so you can verify the callback path without making a real payment.
