Skip to content

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
<?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 callback section, 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.

js
[
    {
        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 name accepts util.icon(), which takes either a Font Awesome class or a path to an image in the plugin's own Assets/.

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
<?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:

PropertyMeaning
$this->amountOrder amount (float)
$this->tradeNoOrder number
$this->configThe configuration the site owner entered
$this->callbackUrlAsync callback URL to hand to the provider
$this->returnUrlWhere the buyer returns after paying
$this->clientIpBuyer's IP
$this->codeThe payment mode chosen by the site owner (an options key)
$this->handleThe plugin directory name

Three ways to present payment

PayEntity::setType() decides how the storefront gets the buyer to the payment page:

ConstantValueBehaviour
TYPE_REDIRECT2Redirect straight to the URL from setUrl()
TYPE_LOCAL_RENDER3Render the payment page locally from the plugin's View/<code>.html — for drawing your own QR code, say
TYPE_SUBMIT4Submit a POST form to the provider

Impl/Signature.php

Required whenever IS_SIGN is true. Implement verification():

php
<?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:

  1. Read the callback definition from Config/Info.php — reject if absent (plugin)
  2. Load Vendor/autoload.php if the plugin has one
  3. If IS_SIGN — check that credentials are configured (reject with credential if not), then call Signature::verification() (reject with sign on failure)
  4. If IS_STATUS — compare the status field (reject with status on mismatch)
  5. Extract the order number and amount and hand them to the order service
  6. Echo the FIELD_RESPONSE value 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:

php
\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.

Released under the MIT License