Назад к блогу
08.04.2026 14:53:38

Интеграция Multicard с 1С-Битрикс D7: инвойс, ссылка на оплату, webhook и сумма в тийинах

Подключение платежной системы к 1С-Битрикс — это не просто добавить кнопку «Оплатить». В реальном проекте нужно создать инвойс на стороне банка или платежного сервиса, получить ссылку на оплату, показать её пользователю, принять webhook, проверить подпись и только после этого корректно перевести оплату в статус PAID=Y внутри Битрикс. Чтобы минимизировать риски и гарантировать стабильность транзакций, разработчиками закладывается профессиональная интеграция платежных систем на базе современного ядра CMS.

В этой статье разберем пример интеграции Multicard с 1С-Битрикс через D7-платежную систему: создание инвойса через POST /payment/invoice, получение checkout_url, обработку callback/webhook, проверку sign и важный момент с суммой в тийинах.

Такая задача относится к полноценной технической доработке интернет-магазина или сервиса на Битрикс. Здесь важно не только отправить запрос в API, но и правильно встроить оплату в логику заказа, платежей, статусов и уведомлений. Похожие задачи мы выполняем в рамках услуги доработки Bitrix и PHP-задачи.

Что нужно реализовать

В базовом сценарии интеграция Multicard с 1С-Битрикс состоит из трех основных этапов:

  1. Создать инвойс в Multicard через POST /payment/invoice и получить ссылку checkout_url.
  2. Показать пользователю кнопку или ссылку для перехода на оплату.
  3. Принять webhook от Multicard, проверить подпись и отметить оплату в Битрикс как PAID=Y.

На первый взгляд логика выглядит простой. Но в реальной интеграции есть несколько критичных деталей: сумма передается не в сумах, а в тийинах, webhook должен быть защищен проверкой подписи, а оплата в Битрикс должна ставиться не по факту возврата пользователя на сайт, а после подтверждения от платежной системы.

Главный нюанс: amount передается в тийинах

Один из самых важных моментов при работе с Multicard — поле amount. В API Multicard сумма передается в тийинах, а не в сумах.

То есть:

  • 1 сум = 100 тийин;
  • 100 сум = 10000 тийин;
  • 15000 сум = 1500000 тийин.

Если передать в amount сумму заказа напрямую из Битрикс, платеж может быть создан на неправильную сумму. Поэтому перед отправкой в Multicard сумму нужно умножать на 100.

private function amountToTiyin(Payment $payment): int
{
    $sumUzs = (float)$payment->getSum();
    $tiyin = (int)round($sumUzs * 100);

    if ($tiyin < 10000) {
        $tiyin = 10000;
    }

    return $tiyin;
}

Это хороший пример того, почему подключение онлайн-оплаты нельзя делать «на глаз». Даже небольшая ошибка в формате суммы, валюте или статусе может привести к некорректным платежам.

Какие настройки нужны в админке Битрикс

Для платежной системы лучше вынести все параметры в настройки обработчика. Это позволит не хранить доступы прямо в коде и удобно переключаться между тестовым и боевым окружением.

  • MC_BASE_URL — базовый URL Multicard для sandbox или production;
  • MC_APP_ID — идентификатор приложения;
  • MC_APP_SECRET — секрет приложения;
  • MC_STORE_ID — ID магазина;
  • MC_LANG — язык платежной страницы;
  • MC_RETURN_SUCCESS_URL — страница успешной оплаты;
  • MC_RETURN_ERROR_URL — страница ошибки оплаты;
  • MC_RETURN_BACK_URL — страница возврата в магазин;
  • MC_CALLBACK_URL — URL webhook-обработчика;
  • MC_ALLOWED_IP — IP, с которого разрешены callback-запросы.

Такой подход особенно важен для интернет-магазинов и сервисов, где платежная интеграция должна быть управляемой: без ручного редактирования файлов при смене ключей, домена, callback-адреса или тестового режима.

Структура обработчика платежной системы D7

Обработчик платежной системы можно разместить по пути: /local/php_interface/include/sale_payment/multicard/handler.php

В нем описываются настройки платежной системы, получение токена, создание инвойса, вывод шаблона оплаты и обработка входящего запроса от Multicard.

Ниже пример структуры обработчика:

<?php

namespace Sale\Handlers\PaySystem;

use Bitrix\Main\Error;
use Bitrix\Main\Localization\Loc;
use Bitrix\Main\Request;
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;
use Bitrix\Sale\PaySystem;
use Bitrix\Sale\Payment;

Loc::loadMessages(__FILE__);

class MulticardHandler extends PaySystem\ServiceHandler
{
    public static function getHandlerDescription()
    {
        return [
            'NAME' => 'Multicard',
            'SORT' => 200,
            'CODES' => [
                'MC_BASE_URL' => [
                    'NAME' => 'Base URL',
                    'DESCRIPTION' => 'https://dev-mesh.multicard.uz/ или https://mesh.multicard.uz/',
                    'SORT' => 100
                ],
                'MC_APP_ID' => [
                    'NAME' => 'application_id',
                    'DESCRIPTION' => 'Идентификатор приложения',
                    'SORT' => 110
                ],
                'MC_APP_SECRET' => [
                    'NAME' => 'secret',
                    'DESCRIPTION' => 'Секрет приложения',
                    'SORT' => 120
                ],
                'MC_STORE_ID' => [
                    'NAME' => 'store_id',
                    'DESCRIPTION' => 'ID магазина',
                    'SORT' => 130
                ],
                'MC_LANG' => [
                    'NAME' => 'lang',
                    'DESCRIPTION' => 'ru/en/uz',
                    'SORT' => 140
                ],
                'MC_RETURN_SUCCESS_URL' => [
                    'NAME' => 'URL успеха',
                    'DESCRIPTION' => 'https://site.ru/payment/success/',
                    'SORT' => 200
                ],
                'MC_RETURN_ERROR_URL' => [
                    'NAME' => 'URL ошибки',
                    'DESCRIPTION' => 'https://site.ru/payment/error/',
                    'SORT' => 210
                ],
                'MC_RETURN_BACK_URL' => [
                    'NAME' => 'URL вернуться в магазин',
                    'DESCRIPTION' => 'https://site.ru/payment/cancel/',
                    'SORT' => 220
                ],
                'MC_CALLBACK_URL' => [
                    'NAME' => 'callback_url',
                    'DESCRIPTION' => 'https://site.ru/local/tools/multicard_webhook.php',
                    'SORT' => 230
                ],
                'MC_ALLOWED_IP' => [
                    'NAME' => 'IP Multicard',
                    'DESCRIPTION' => '195.158.26.90',
                    'SORT' => 240
                ],
            ],
        ];
    }

    protected function getTemplatePath()
    {
        return '/local/php_interface/include/sale_payment/multicard/template/template.php';
    }

    private function amountToTiyin(Payment $payment): int
    {
        $sumUzs = (float)$payment->getSum();
        $tiyin = (int)round($sumUzs * 100);

        if ($tiyin < 10000) {
            $tiyin = 10000;
        }

        return $tiyin;
    }
}

Вынесение логики в отдельный D7-обработчик делает интеграцию понятнее и безопаснее. Не нужно размазывать платежные запросы по компонентам, шаблонам или кастомным страницам.

Получение токена Multicard

Перед созданием инвойса нужно получить токен через POST /auth. Для этого используются application_id и secret, которые хранятся в настройках платежной системы.

private function getToken(Payment $payment): string
{
    $baseUrl = rtrim((string)$this->getBusinessValue($payment, 'MC_BASE_URL'), '/');
    $appId = (string)$this->getBusinessValue($payment, 'MC_APP_ID');
    $secret = (string)$this->getBusinessValue($payment, 'MC_APP_SECRET');

    if ($baseUrl === '' || $appId === '' || $secret === '') {
        throw new \RuntimeException('Multicard: заполните MC_BASE_URL, MC_APP_ID, MC_APP_SECRET');
    }

    $http = new HttpClient();
    $http->setHeader('Content-Type', 'application/json');

    $body = Json::encode([
        'application_id' => $appId,
        'secret' => $secret
    ], JSON_UNESCAPED_UNICODE);

    $http->post($baseUrl . '/auth', $body);

    $status = (int)$http->getStatus();

    if ($status < 200 || $status >= 300) {
        throw new \RuntimeException('Multicard /auth HTTP ' . $status . ': ' . $http->getResult());
    }

    $resp = json_decode((string)$http->getResult(), true) ?: [];
    $token = (string)($resp['token'] ?? ($resp['data']['token'] ?? ''));

    if ($token === '') {
        throw new \RuntimeException('Multicard: token not found: ' . $http->getResult());
    }

    return $token;
}

На этом этапе важно логировать ответы API. Если токен не приходит, нужно видеть не только «ошибка оплаты», а конкретный HTTP-статус и тело ответа Multicard. Это сильно ускоряет отладку.

Создание инвойса и получение checkout_url

После получения токена можно создавать инвойс через POST /payment/invoice. В запрос передаются ID магазина, сумма в тийинах, внутренний ID инвойса, язык, URL возврата и callback URL.

private function createInvoice(Payment $payment, array $payload): array
{
    $baseUrl = rtrim((string)$this->getBusinessValue($payment, 'MC_BASE_URL'), '/');
    $token = $this->getToken($payment);

    $http = new HttpClient();
    $http->setHeader('Authorization', 'Bearer ' . $token);
    $http->setHeader('Content-Type', 'application/json');

    $http->post(
        $baseUrl . '/payment/invoice',
        Json::encode($payload, JSON_UNESCAPED_UNICODE)
    );

    $status = (int)$http->getStatus();

    if ($status < 200 || $status >= 300) {
        throw new \RuntimeException('Multicard /payment/invoice HTTP ' . $status . ': ' . $http->getResult());
    }

    $resp = json_decode((string)$http->getResult(), true) ?: [];

    if (isset($resp['success']) && $resp['success'] === false) {
        $code = $resp['error']['code'] ?? 'ERROR';
        $details = $resp['error']['details'] ?? 'Unknown';

        throw new \RuntimeException("Multicard invoice error {$code}: {$details}");
    }

    return (array)($resp['data'] ?? $resp);
}

В ответе нужно получить как минимум uuid и checkout_url. uuid удобно сохранить в поле PS_INVOICE_ID, а ссылку на оплату передать в шаблон.

Создание платежа в initiatePay

Метод initiatePay отвечает за запуск оплаты. В нем мы берем заказ, платеж, настройки обработчика, формируем payload для Multicard, создаем инвойс и показываем пользователю ссылку на оплату.

public function initiatePay(Payment $payment, ?Request $request = null)
{
    $result = new PaySystem\ServiceResult();

    try {
        $order = $payment->getOrder();
        $storeId = (int)$this->getBusinessValue($payment, 'MC_STORE_ID');

        if ($storeId <= 0) {
            $result->addError(new Error('Multicard: заполните MC_STORE_ID'));
            return $result;
        }

        $lang = (string)$this->getBusinessValue($payment, 'MC_LANG');
        if (!in_array($lang, ['ru', 'en', 'uz'], true)) {
            $lang = 'ru';
        }

        $invoiceId = (string)$payment->getId();
        $amount = $this->amountToTiyin($payment);

        $successUrl = (string)$this->getBusinessValue($payment, 'MC_RETURN_SUCCESS_URL');
        $errorUrl = (string)$this->getBusinessValue($payment, 'MC_RETURN_ERROR_URL');
        $backUrl = (string)$this->getBusinessValue($payment, 'MC_RETURN_BACK_URL');
        $callbackUrl = (string)$this->getBusinessValue($payment, 'MC_CALLBACK_URL');

        if ($callbackUrl === '') {
            $callbackUrl = 'https://' . (string)$_SERVER['HTTP_HOST'] . '/local/tools/multicard_webhook.php';
        }

        $payload = [
            'store_id' => $storeId,
            'amount' => $amount,
            'invoice_id' => $invoiceId,
            'lang' => $lang,
            'return_url' => $successUrl,
            'return_error_url' => $errorUrl,
            'return_back_url' => $backUrl,
            'callback_url' => $callbackUrl,
        ];

        $data = $this->createInvoice($payment, $payload);

        $uuid = (string)($data['uuid'] ?? '');
        $checkoutUrl = (string)(
            $data['checkout_url']
            ?? ($data['invoice']['checkout_url'] ?? '')
            ?? ($data['short_link'] ?? '')
        );

        if ($uuid === '' || $checkoutUrl === '') {
            throw new \RuntimeException('Multicard: uuid/checkout_url not found');
        }

        $payment->setField('PS_INVOICE_ID', $uuid);
        $payment->setField(
            'PS_STATUS_DESCRIPTION',
            Json::encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );

        $order->save();

        $this->setExtraParams([
            'CHECKOUT_URL' => $checkoutUrl,
            'SHORT_LINK' => (string)($data['short_link'] ?? ''),
            'DEEPLINK' => (string)($data['deeplink'] ?? ''),
            'INVOICE_ID' => $invoiceId,
            'UUID' => $uuid,
            'AMOUNT_TIYIN' => (int)($data['amount'] ?? $amount),
        ]);

        return $this->showTemplate($payment, 'template');

    } catch (\Throwable $e) {
        $result->addError(new Error($e->getMessage()));
        return $result;
    }
}

Важно не считать оплату успешной на этом этапе. Здесь мы только создали инвойс и получили ссылку. Реальная оплата должна подтверждаться через webhook или дополнительную проверку статуса.

Это частая ошибка при подключении онлайн-оплаты: разработчик переводит заказ в оплаченный статус после редиректа пользователя, хотя платеж еще не подтвержден платежной системой. В нормальной интеграции заказ должен становиться оплаченным только после серверного подтверждения.

Шаблон кнопки оплаты

После создания инвойса пользователю нужно показать ссылку или кнопку для перехода на оплату. Шаблон можно разместить по пути: /local/php_interface/include/sale_payment/multicard/template/template.php

<?php
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) die();

use Bitrix\Main\Localization\Loc;
Loc::loadMessages(__FILE__);

$extra = $this->getExtraParams();

$checkoutUrl = (string)($extra['CHECKOUT_URL'] ?? '');
$shortLink = (string)($extra['SHORT_LINK'] ?? '');
$deeplink = (string)($extra['DEEPLINK'] ?? '');
$invoiceId = (string)($extra['INVOICE_ID'] ?? '');
$uuid = (string)($extra['UUID'] ?? '');
$amountTiyin = (int)($extra['AMOUNT_TIYIN'] ?? 0);

$amountUzs = $amountTiyin > 0 ? number_format($amountTiyin / 100, 2, '.', ' ') : '';
?>

<h2><?= Loc::getMessage('MC_TITLE') ?></h2>
<p><b><?= Loc::getMessage('MC_INVOICE') ?>:</b> <?= htmlspecialcharsbx($invoiceId) ?></p>
<p><b>UUID:</b> <?= htmlspecialcharsbx($uuid) ?></p>
<p><b><?= Loc::getMessage('MC_AMOUNT') ?>:</b> <?= htmlspecialcharsbx($amountUzs) ?> UZS</p>

<p>
    <a href="<?= htmlspecialcharsbx($checkoutUrl) ?>" target="_blank" rel="noopener">
        <?= Loc::getMessage('MC_PAY') ?>
    </a>
</p>

<?php if ($deeplink): ?>
    <p>
        <a href="<?= htmlspecialcharsbx($deeplink) ?>" target="_blank" rel="noopener">
            <?= Loc::getMessage('MC_OPEN_APP') ?>
        </a>
    </p>
<?php endif; ?>

Языковые файлы для шаблона

Русский файл (lang/ru/template.php):

<?php
$MESS['MC_TITLE'] = 'Оплата через Multicard';
$MESS['MC_INVOICE'] = 'Инвойс';
$MESS['MC_AMOUNT'] = 'Сумма';
$MESS['MC_PAY'] = 'Перейти к оплате';
$MESS['MC_OPEN_APP'] = 'Открыть в приложении';

Каноническая обработка webhook по стандартам D7

В архитектуре Bitrix D7 метод processRequest() внутри хендлера должен только валидировать входящие данные, проверять подпись и возвращать объект ServiceResult с массивом полей для обновления. Сам процесс записи данных в БД и перевод в статус оплаченного делегируется ядру.

Добавим метод валидации подписи в класс MulticardHandler:

public function getPaymentIdFromRequest(Request $request)
{
    $input = json_decode((string)$request->getInput(), true);
    return isset($input['invoice_id']) ? (int)$input['invoice_id'] : null;
}

public function processRequest(Payment $payment, Request $request)
{
    $result = new PaySystem\ServiceResult();
    $input = json_decode((string)$request->getInput(), true);

    if (!is_array($input)) {
        $result->addError(new Error('Invalid JSON'));
        return $result;
    }

    $uuid = (string)($input['uuid'] ?? ($input['invoice_uuid'] ?? ''));
    $invoiceId = (string)($input['invoice_id'] ?? '');
    $amount = (string)($input['amount'] ?? '');
    $sign = (string)($input['sign'] ?? '');

    $secret = (string)$this->getBusinessValue($payment, 'MC_APP_SECRET');
    $str = $uuid . $invoiceId . $amount . $secret;

    if ($sign !== sha1($str) && $sign !== md5($str)) {
        $result->addError(new Error('Bad sign signature'));
        return $result;
    }

    $status = (string)($input['status'] ?? '');
    $isPaid = ($status === 'success') || ($status === '' && !empty($input['payment_time']));

    $result->setPsData([
        'PS_INVOICE_ID' => $uuid,
        'PS_STATUS' => $isPaid ? 'Y' : 'N',
        'PS_STATUS_CODE' => $status ?: ($isPaid ? 'success' : 'progress'),
        'PS_STATUS_DESCRIPTION' => json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        'PS_RESPONSE_DATE' => new DateTime(),
    ]);

    if ($isPaid) {
        $result->setOperationType(PaySystem\ServiceResult::MONEY_COMING);
    }

    return $result;
}

Входная точка: публичный вебхук на сервере

Благодаря канонической архитектуре D7, файл /local/tools/multicard_webhook.php избавляется от «костылей» ручного изменения статусов заказа. Мы используем штатный PaySystem\Manager::handlePaymentNotify(), который гарантирует корректный запуск всех зависимых событий (чеки, почта, склады):

<?php
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

use Bitrix\Main\Context;
use Bitrix\Main\Web\Json;
use Bitrix\Sale\Order;
use Bitrix\Sale\PaySystem\Manager as PaySystemManager;
use Bitrix\Sale\Internals\PaymentTable;

header('Content-Type: application/json; charset=utf-8');

$request = Context::getCurrent()->getRequest();
$input = json_decode((string)$request->getInput(), true);

if (!is_array($input) || empty($input['invoice_id'])) {
    http_response_code(400);
    echo Json::encode(['success' => false, 'message' => 'Bad request']);
    exit;
}

$paymentId = (int)$input['invoice_id'];
$row = PaymentTable::getById($paymentId)->fetch();

if (!$row) {
    http_response_code(404);
    echo Json::encode(['success' => false, 'message' => 'Payment not found']);
    exit;
}

$service = PaySystemManager::getObjectById((int)$row['PAY_SYSTEM_ID']);
if ($service) {
    $allowedIp = (string)$service->getBusinessValue(null, 'MC_ALLOWED_IP');
    if ($allowedIp !== '' && $_SERVER['REMOTE_ADDR'] !== $allowedIp) {
        http_response_code(403);
        echo Json::encode(['success' => false, 'message' => 'Forbidden IP']);
        exit;
    }

    $result = PaySystemManager::handlePaymentNotify($service, $request);
    
    if ($result->isSuccess()) {
        echo Json::encode(['success' => true]);
    } else {
        http_response_code(500);
        $errors = $result->getErrors();
        echo Json::encode(['success' => false, 'message' => $errors[0]->getMessage()]);
    }
}
?>

Why нельзя ставить PAID=Y только по return_url

URL возврата нужен для пользователя: показать страницу успеха, ошибки или возврата в магазин. Но он не должен быть единственным источником истины для оплаты. Пользователь может попасть на return URL в обход реального факта списания денег. Статус PAID=Y должен ставиться строго после серверного подтверждения.

Типовые ошибки при интеграции Multicard с Битрикс

  • Сумма передается не в тийинах: транзакции создаются на сумму в 100 раз меньше реальной, если не использовать метод умножения на 100 с округлением round().
  • Хардкод IP-адресов: блокирует работу платежки при плановых изменениях инфраструктуры банка. IP должен считываться через MC_ALLOWED_IP.
  • Ручной вызов setPaid('Y') вне контекста PaySystem D7: ломает логику кассовых чеков (ФЗ-54/смежные законы онлайн-касс), так как ручное сохранение заказа обходит внутренние триггеры модуля sale.

Итог

Каноническая интеграция Multicard на базе ядра 1С-Битрикс D7 разделяет зоны ответственности: хендлер генерирует инвойсы и проверяет подписи, а системный менеджер handlePaymentNotify безопасно управляет статусами транзакций.

Если вашему проекту необходима качественная и архитектурно чистая интеграция платежных систем, подключение Multicard, настройка защищенных вебхуков или аудит текущей платежной логики интернет-магазина, вы можете отправить нам задачу через страницу контактов.