Разработка плагина для способа оплаты

С версии Moguta.CMS 10.9.0 способы оплаты являются плагинами.
Прежде чем приступить к разработке оплаты, рекомендуем ознакомиться с инструкцией по разработке плагинов.


  1. Убедитесь, что у вас на сайте способы оплаты работают из плагинов. Как включить новую систему оплат?
  2. Ознакомьтесь с таблицей оплат в базе данных (Таблица #PREFIX#payment).
    Это та же таблица, что используется и в старой системе, но в ней добавлены несколько новых полей (code, plugin, icon) и убрано устаревшее поле "add_security".

     
    НазваниеТипОписание
    namevarchar(1024) Название оплаты для менеджера (Отображается в административной панели). По умолчанию - пустая строка;
    public_namevarchar(1024) Название оплаты для клиента (Отображается в публичной части сайта). По умолчанию - пустая строка;
    activityint(1) Флаг активности оплаты. По умолчанию - 0;
    paramArraytextJSON массив параметров оплаты;
    urlArrayvarchar(1024) JSON массив ссылок оплаты (отображаются в настройках оплаты);
    ratedoubleСкидка/Наценка на способ оплаты. Коэффициент изменения стоимости, т. е. значение 0.1 означает наценку в 10%, а значение -0.05 - скидку в 5%;
    sortint(11) Сортировка оплаты. Чем выше число - тем выше по списку отображается оплата;
    permissionvarchar(5)Кому доступна оплата. all - всем, fiz - только физическим лицам, yur - только юридическим;
    codevarchar(5)UNIQUE Уникальный текстовый идентификатор оплаты;
    pluginvarchar(255)Название папки плагина оплаты;
    iconvarchar(511)Ссылка на иконку оплаты (Отображается в публичной части сайта и в разделе заказов административной панели);
    logstinyint(1)Флаг поддержки логирования. 1 - оплата поддерживает логирование, 0 - не поддерживает.

    И новые и старые оплаты хранятся в этой таблице одновременно, но у старых оплат code всегда имеет вид "old#(идентификатор оплаты)", а у новых задаётся из плагина оплаты (как правило это название папки плагина) или имеет вид "custom#(идентификатор оплаты)", если это пользовательская оплата.

  3. Ознакомьтесь с документацией к модели оплаты. Большая часть методов этой модели используются в системе, а для разработки плагина понадобятся всего несколько:
    • Метод для добавления способа оплаты, нужно использовать при активации плагина оплаты через mgActivateThisPlugin.
    • Метод отрисовки формы оплаты. Сам метод напрямую вызывать не нужно, он создаёт важный хук отрисовки формы оплаты, уникальный для каждого плагина. Если пользователь оформляет заказ с оплатой, которая работает с плагином "payment-example", то, во время отрисовки формы оплаты, создастся хук "payment-example". Плагин должен обработать этот хук и отдать html с формой, которая отобразиться после оформления заказа.
    • Метод перехвата запросов в контроллер оплат. Аналогично методу выше, вызывать его напрямую не нужно, он тоже создаёт хук, который нужно обработать в плагине - удостовериться, что запрос предназначен плагину,  проверить платёж, сделать заказ оплаченным через метод actionWhenPayment, если для этого соблюдены все условия. Как правило, этот метод срабатывает когда приходит нотификация от банка или пользователь возвращается на сайт после оплаты.
    • Метод для получения настроек оплаты. Возвращает удобный, для использования в коде, массив со всеми настройками оплаты, которые были заданы во время создания оплаты и заполнены пользователем в административной панели.
    • Методы для получения самой оплаты, такие как getPaymentByCodegetPaymentByPlugin, реже getPaymentById или getPayment.
    • Метод для записи логов. Такие логи защищены от просмотра 3-ми лицами, структурированы, их можно включить или выключить в настройках оплаты, а, также, скачать или удалить там же. Рекомендуем использовать этот метод на разных этапах работы плагина для сбора отладочной информации на случай, если у пользователя возникнут какие-либо проблемы с оплатой. Особенно в местах, в которых происходят запросы к банкам или сбор данных для отрисовки формы оплаты.
  4. Используйте шаблон плагина способа оплаты или пример плагина способа оплаты Qiwi для создания плагина.
Рассмотрим работу плагина оплаты на примере плагина оплаты Qiwi.

test

  1. Структура плагина:
    • index.php - файл с основным программным кодом плагина и его метаданными;
    • views/form.php - файл с вёрсткой формы оплаты. Рекомендуется выносить отдельно, чтобы в коробочных версиях было проще доработать внешний вид формы или её содержимое;
    • src/icon.png - иконка оплаты, её мы сразу укажем во время создания оплаты для вывода в публичной части сайта и странице заказов административной.

  2.  Метаданные плагина, такие же как и в любом другом плагине, с одним важным отличием, в конце должно быть указано "Edititon: PAYMENT". Именно по этому параметру движок сможет понять, что плагин является плагином оплаты и будет выводить его в разделе настроек оплат, а не в разделе плагины.

  3. Конструктор плагина добавляет обработку 3-х важных хуков.
    • При активации плагина будет вызван метод "activate", в котором создаётся оплата;
    • На событие "payment-qiwi" будет вызван метод "getOrderForm", который возвращает html форму оплаты;
    • На событие "Models_Payment_handleRequest" будет вызван метод "webhookIntercept", который перехватывает нотификацию от банка и, при соблюдении всех условий, делает заказ оплаченным.

    new PaymentQiwi;
    
    class PaymentQiwi {
        public static $pluginName = 'payment-qiwi';
    
        public function __construct() {
            mgActivateThisPlugin(__FILE__, [__CLASS__, 'activate']);
    
            mgAddAction(self::$pluginName, [__CLASS__, 'getOrderForm'], 1);
    
            mgAddAction('Models_Payment_handleRequest', [__CLASS__, 'webhookIntercept'], 1);
        }
    ...
  4. Метод activate проверяет есть ли оплата и если нет, то создаёт её используя метод Models_Payment::addPayment.

    public static function activate() {
        $currentPayment = Models_Payment::getPaymentByPlugin(self::$pluginName);
        if (!$currentPayment) {
            $name = 'Qiwi';
    
            $icon = SITE.'/mg-plugins/'.self::$pluginName.'/src/icon.png';
    
            $defaultParams = [
                [
                    'name' => 'publicKey',
                    'title' => 'Публичный ключ',
                    'type' => 'text',
                    'value' => '',
                ],
                [
                    'name' => 'secretKey',
                    'title' => 'Секретный ключ',
                    'type' => 'crypt',
                    'value' => '',
                ],
            ];
    
            $urls = [
                [
                    'type' => 'info',
                    'title' => 'Result URL',
                    'link' => SITE.'/payment?payment=qiwi',
                ],
            ];
    
            Models_Payment::addPayment(
                self::$pluginName,
                $name,
                $name,
                self::$pluginName,
                $defaultParams,
                $icon,
                0,
                $urls
            );
        }
    }
  5. Метод getOrderForm подготавливает переменные, такие как $uniqOrderId, $publicKey, $orderAmount, $currency, $phone, $email, $successUrl и отдаёт html форму оплаты, полученную через буферизацию вывода файла views/form.php,  в котором эти переменные используются.
    public static function getOrderForm($args) {
        $result = $args['result'];
        $orderId = $args['args'][1];
        $orderModel = new Models_Order();
        $orders = $orderModel->getOrder('`id` = '.DB::quoteInt($orderId));
        $order = $orders[$orderId];
    
        $uniqOrderId = $orderId.'-'.time();
    
        $options = Models_Payment::getPaymentParams(self::$pluginName, true);
        $publicKey = $options['publicKey'];
    
        // Отменяем предыдущий неоплаченный заказ
        if (!empty($_SESSION['qiwiApi']['orderID'])) {
            $lastOrderId = $_SESSION['qiwiApi']['orderID'];
    
            $secretKey = $options['secretKey'];
    
            $url = 'https://api.qiwi.com/partner/bill/v1/bills/'.$lastOrderId.'/reject';
    
            $headers = [
                'Accept: application/json',
                'Content-Type: application/json',
                'Authorization: Bearer '.$secretKey,
            ];
    
            $curlHandler = curl_init($url);
            $curlOptions = [
                CURLOPT_POST => true,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => $headers,
            ];
            curl_setopt_array($curlHandler, $curlOptions);
            curl_exec($curlHandler);
            curl_close($curlHandler);
        }
    
        $orderAmount = round($order['summ'] + $order['delivery_cost'], 2);
    
        $currency = MG::getSetting('shopCurrencyIso');
        if ($currency === 'RUR') {
            $currency = 'RUB';
        }
        
        $phone = '';
        if (!empty($order['phone'])) {
            $phone = str_replace([' ', '(', ')', '-'], '', $order['phone']);
            $phone = str_replace('+7', '8', $phone);
        }
    
        $email = $order['user_email'];
        if (empty($email)) {
            $email = $order['contact_email'];
        }
    
        $successUrl = SITE.'/payment?payment=qiwi';
    
        $_SESSION['qiwiApi']['orderID'] = $uniqOrderId;
    
        ob_start();
        require('views/form.php');
        $result = ob_get_contents();
        ob_end_clean();
    
        return $result;
    }
    <style>
      #qiwiSendForm {
        background: rgb(255, 140, 0);
        color: white;
        border-radius: 24px;
        font-weight: 500;
        padding: 15px 50px;
      }
    
      #qiwiSendForm:hover {
        background: rgb(255, 130, 0);
      }
    style>
    
    <form method="get" action="https://oplata.qiwi.com/create" accept-charset="UTF-8">
      <input type="hidden" name="publicKey" value="" />
      <input type="hidden" name="billId" value="" />
      <input type="hidden" name="amount" value="" />
      <input type="hidden" name="phone" value="" />
      <input type="hidden" name="email" value="" />
      <input type="hidden" name="successUrl" value="" />
      <input type="submit" value="Оплатить" id="qiwiSendForm">
    form>
  6. Метод webhookIntercept перехватывает запрос в контроллер оплат, удостоверяется, что этот запрос именно для оплаты этого плагина и пришли все необходимые данные. Проверяет, что заказ оплачен в банке и делает его оплаченным в магазине через метод Controllers_Payment::actionWhenPayment.
    public static function webhookIntercept($args) {
        $result = $args['result'];
        if ($_GET['payment'] !== 'qiwi') {
            return $result;
        }
    
        $payment = Models_Payment::getPaymentByCode(self::$pluginName, true);
        $options = $payment['paramArray'];
    
        $secretKey = $options['secretKey'];
    
        $lastOrderId = $_SESSION['qiwiApi']['orderID'];
    
        $url = 'https://api.qiwi.com/partner/bill/v1/bills/' . $lastOrderId;
        $headers = [
            'Accept: application/json',
            'Content-Type: application/json',
            'Authorization: Bearer ' . $secretKey,
        ];
        $curlOptions = [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
        ];
    
        $curlHandler = curl_init($url);
        curl_setopt_array($curlHandler, $curlOptions);
    
        $response = curl_exec($curlHandler);
        curl_close($curlHandler);
    
        $response = json_decode($response, true);
        $status = $response['status']['value'];
    
        if ($status === 'PAID') {
            $orderId = preg_replace('/-.*/is', '', $response['billId']);
            $orderAmount = $response['amount']['value'];
    
            $orderModel = new Models_Order();
            $orders = $orderModel->getOrder(
                '`id` => ' . DB::quoteInt($orderId) . ' AND ' .
                'ROUND(`summ` + `delivery_cost`) = ' . DB::quoteFloat($orderAmount) . ' AND ' .
                '`payment_id` = ' . DB::quoteInt($payment['id'])
            );
    
            if (empty($orders)) {
                $result = [
                    'status' => 'fail',
                    'message' => 'ERR: Заказ был изменен! Была произведена оплата ' . $orderAmount . ' ' . $response['amount']['currency'] . ' по некорректному счету!',
                ];
                return $result;
            } else {
                $order = array_shift($orders);
                $result = [
                    'status' => 'success',
                    'message' => 'Вы успешно оплатили заказ. Спасибо!',
                ];
                if (intval($order['status_id']) === 0 || intval($order['status_id']) === 1) {
                    Controllers_Payment::actionWhenPayment([
                        'paymentOrderId' => $orderId,
                        'paymentAmount' => $orderAmount,
                        'paymentID' => $payment['id']
                    ]);
                }
            }
        } else {
            $result = [
                'status' => 'fail',
                'message' => 'Возникла ошибка в оплате заказа.',
            ];
        }
    
        return $result;
    }

Примечание: В системе предусмотрены стандартные страницы для успешной или неудачной оплаты, куда можно пренаправить пользователя после оплаты. Это #SITE#/payment?payStatus=success и #SITE#/payment?payStatus=fail соответственно. Добавьте их в список ссылок при создании оплаты через метод Models_Payment::addPayment, если их можно указать в настройках перенаправления в банке.

Изменения в версии системы 12.0.0

С версии 12.0.0 в систему добавлен ряд важных изменений, а именно поддержка маркировки товаров, параметров фискализации по 54 ФЗ и отправка второго чека из модального окна заказа. Ниже представлена информация о том, как эти изменения касаются плагинов оплат.
  • Маркировка товара - теперь при формировании оплаты плагином можно отправлять маркировку товара, если она была заполнена при редактировании заказа. Для этого используйте метод модели заказа getOrderItemMarkCodes, он позволяет получить маркировку для конкретной позиции заказа; 
$orderModel = new Models_Order();
if (
    method_exists($orderModel, 'getOrderItemMarkCodes') &&
    is_callable([$orderModel, 'getOrderItemMarkCodes'])
) {
    $orderId = (int) $order['id'];
    $productId = (int) $orderItem['id'];
    $variantId = (int) $orderItem['variant_id'];

    $markCodes = $orderModel->getOrderItemMarkCodes($orderId, $productId, $variantId);

    if (!$markCodes) {
        $items[] = $itemData;
        continue;
    }

    foreach ($markCodes as $markCode) {
        $itemData['Quantity'] = 1;
        $itemData['MarkCode'] = [$markCode];
        $items[] = $itemData;
    }
}
  •  Параметры фискализации - теперь у каждого товара в заказе можно указать ставку НДС, меру количества предмета расчёта и признак предмета расчёта. Эта информация содержится непосредственно в составе заказа и может быть использована для передачи параметров чека. Значения этих параметров такие же, как значения соответсвующих тегов ФФД;
$options = self::getOptions();
$tax = $options['tax'];

if (!empty($orderItem['vat'])) {
    $vatToTax = [
        1 => 'vat20', // '20%',
        2 => 'vat10', // '10%',
        3 => 'vat120', // '20/120',
        4 => 'vat110', // '10/110',
        5 => 'vat0', // '0%',
        6 => 'none', // 'Без НДС',
        7 => 'vat5', // '5%',
        8 => 'vat7', // '7%',
        9 => 'vat105', // '5/105',
        10 => 'vat107', // '7/107',
    ];

    $tax = $vatToTax[(int) $orderItem['vat']];
}
  •  Отправка второго чека - плагины оплаты могут сохранять информацию о переданных чеках через метод модели заказа setPaymentInfoAboutReceipt. Если плагин оплаты не передаёт информацию для чеков, то вызывать этот метод не нужно. Если через этот метод будет сохранена информация, что первый чек уже отправлен и поддерживается второй чек, тогда в модальном окне заказа в административной панели появится кнопка "Чек зачёта предоплаты". По нажатию на неё будет вызвано событие отправки второго чека Models_Order_printReceipt, которое можно обработать в плагине.
public function __construct() {
    mgAddAction('Models_Order_printReceipt', [__CLASS__, 'sendSecondReceipt'], 1);
}

public static function sendSecondReceipt($args) {
    $result = $args['result'];

    $orderId = (int) $args['args']['id'];

    if (!$orderId) {
        return $result;
    }

    $order = self::getOrderById($orderId);
    $payment = Models_Payment::getPaymentByCode(self::$pluginName);

    if ($order['payment_id'] !== $payment['id']) {
        return $result;
    }

    $result = self::sendPrepaymentClosingReceipt($orderId);

    if ($result) {
        $orderModel = new Models_Order();
        $orderModel->setPaymentInfoAboutReceipt($orderId, true, true, true);
    }

    return $result;
}