Вывод виджета и кнопки оплаты Яндекс Пэй на детальной странице товара
Rus
Eng
Вывод виджета и кнопки оплаты Яндекс Пэй на детальной странице товара
Заказать сайт
Обратившись ко мне вы получите надежного и сведущего исполнителя, который быстро и качественно реализует любую задачу для Вас и Вашего бизнеса.
Рекомендую

Хостинг для сайта

Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.

Перейти Партнёрская ссылка. Для вас цена не меняется.

Задача

Нужно вывести на детальной странице товара две сущности Yandex Pay: кнопку оплаты и виджет «Сплит». Оплата инициируется на клиенте, серверная часть создаёт заказ и возвращает ссылку на платёжную форму. После оплаты Yandex редиректит пользователя обратно на сайт, где финализируется заказ в Bitrix.

Схема работы:

  1. На детальной странице рендерятся контейнеры под кнопку и виджет.
  2. JS-скрипт подгружает SDK Yandex Pay и монтирует в них виджеты.
  3. По клику на кнопку уходит запрос на бэкенд, тот создаёт платёж через API Yandex и возвращает ссылку.
  4. Пользователь уходит на оплату, после чего Yandex редиректит его на sale_ps_success.php.
  5. Тот по сессионной ссылке и данным из редиректа создаёт заказ в Bitrix.

Клиентский скрипт

Конфиг и класс интеграции живут в одном файле — так проще править окружение и не расползаться по коду при изменении селекторов. Ссылку возврата можно указать прямо в конфигурации загрузчика Yandex — параметр baseUrl в блоке redirect, туда Yandex и вернёт пользователя после оплаты. Лучше всего подключать этот файл в component_epilog.php детального шаблона или в script.js шаблона сайта.

const YA_PAY_CONFIG = {
    merchantId: '<?=$merchantID?>',
    orderId: (+new Date).toString(36),

    sdk: {
        url: 'https://pay.yandex.ru/sdk/v1/pay.js',
        async: true,
        globalPromiseKey: 'uimaYaPaySdkPromise'
    },

    paymentEnv: 'Sandbox',
    version: 4,
    currencyCode: 'Rub',
    apiCurrencyCode: 'RUB',
    availablePaymentMethods: ['CARD', 'SPLIT'],

    product: {
        id: "<?=$arResult['ID']?>",
        name: "<?=CUtil::JSEscape($arResult['NAME'])?>",
        price: parseFloat("<?=$arResult['PRICES']['DISCOUNT_PRICE']?>") || 0
    },

    cart: {
        itemQuantity: { count: '1' }
    },

    purpose: 'Оплата газоаналитического оборудования на сайте domain.ru',

    api: {
        createOrderUrl: '/api/ya/orders/',
        method: 'POST',
        headers: { 'content-type': 'application/json' }
    },

    ttl: 86400,

    redirect: {
        baseUrl: 'https://domain.ru/bitrix/tools/sale_ps_success.php',
        sandbox: '1',
        type: 'yapay',
        actions: {
            error: 'error',
            success: 'success'
        }
    },

    selectors: {
        payButton: '.ya--pay',
        splitWidget: '.yandex-pay--card-widget',
        elementTools: '#elementTools',
        price: '.price--val',
        payButtonIdPrefix: 'ya-pay-container'
    },

    widgets: {
        payButton: {
            widgetType: 'BnplPreview',
            padding: 'Default'
        },
        split: {
            widgetType: 'Ultimate',
            hasCheckoutButton: false
        }
    },

    split: {
        availablePaymentMethods: ['SPLIT'],
        datasetAmountKey: 'amount',
        mountDelayMs: 50,
        amountRegex: /(\d[\d\s]*(?:[.,]\d{1,2})?)\s*₽/,
        observerOptions: {
            childList: true,
            subtree: true,
            characterData: true
        }
    }
};

class YaPayIntegration {
    constructor(config = YA_PAY_CONFIG) {
        this.config = config;
        this.orderId = config.orderId || (+new Date).toString(36);
    }

    async init() {
        try {
            await this.loadSdk();

            await this.initPayButtons().catch((e) => console.error(e));

            try {
                this.initSplitWidgets();
            } catch (e) {
                console.error(e);
            }
        } catch (err) {
            console.error('Не удалось загрузить YaPay SDK:', err);
        }
    }

    loadSdk() {
        if (window.YaPay) return Promise.resolve();

        const cfg = this.config;
        const promiseKey = cfg.sdk.globalPromiseKey;

        if (window[promiseKey]) return window[promiseKey];

        window[promiseKey] = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = cfg.sdk.url;
            script.async = cfg.sdk.async;
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script);
        });

        return window[promiseKey];
    }

    resolveEnum(enumObject, key) {
        if (!enumObject || key == null) return key;
        return enumObject[key] != null ? enumObject[key] : key;
    }

    initPayButtons() {
        const YaPay = window.YaPay;
        const cfg = this.config;

        const rawPrice = cfg.product.price || 0;
        const formattedPrice = rawPrice.toFixed(2);

        const paymentData = {
            env: this.resolveEnum(YaPay.PaymentEnv, cfg.paymentEnv),
            version: cfg.version,
            currencyCode: this.resolveEnum(YaPay.CurrencyCode, cfg.currencyCode),
            merchantId: cfg.merchantId,
            orderId: this.orderId,
            cart: {
                items: [{
                    discountedUnitPrice: formattedPrice,
                    productId: cfg.product.id,
                    title: cfg.product.name,
                    total: formattedPrice,
                    quantity: cfg.cart.itemQuantity
                }],
                total: { amount: formattedPrice }
            },
            totalAmount: rawPrice,
            availablePaymentMethods: cfg.availablePaymentMethods
        };

        return YaPay.createSession(paymentData, {
            onPayButtonClick: () => this.createPaymentAndGetUrl(formattedPrice),
            onFormOpenError: (reason) => {
                console.error('Ошибка платежной формы Яндекс Пэй: ' + reason);
            }
        }).then((paymentSession) => {
            const targets = document.querySelectorAll(cfg.selectors.payButton);

            if (!targets.length) {
                console.warn('Не найдено ни одного контейнера .ya--pay — пропускаю.');
                return;
            }

            let mounted = 0;

            targets.forEach((el, i) => {
                if (!el.id) {
                    el.id = `${cfg.selectors.payButtonIdPrefix}-${i}-${this.orderId}`;
                }

                try {
                    paymentSession.mountButton(el, {
                        widgetType: this.resolveEnum(YaPay.WidgetType, cfg.widgets.payButton.widgetType),
                        padding: this.resolveEnum(YaPay.WidgetPaddingType, cfg.widgets.payButton.padding)
                    });
                    mounted++;
                } catch (e) {
                    console.error('Ошибка mountButton для #' + el.id + ':', e);
                }
            });
        });
    }

    createPaymentAndGetUrl(formattedPrice) {
        const cfg = this.config;

        const params = {
            sessid: (window.BX && BX.bitrix_sessid) ? BX.bitrix_sessid() : '',
            orderId: this.orderId,
            availablePaymentMethods: cfg.availablePaymentMethods,
            purpose: cfg.purpose,
            cart: {
                items: [{
                    discountedUnitPrice: formattedPrice,
                    productId: cfg.product.id,
                    title: cfg.product.name,
                    total: formattedPrice,
                    quantity: cfg.cart.itemQuantity
                }],
                total: { amount: formattedPrice }
            },
            currencyCode: cfg.apiCurrencyCode,
            redirectUrls: {
                onError: this.createRedirectUrl(cfg.redirect.actions.error),
                onSuccess: this.createRedirectUrl(cfg.redirect.actions.success)
            },
            ttl: cfg.ttl
        };

        return fetch(cfg.api.createOrderUrl, {
            method: cfg.api.method,
            body: JSON.stringify(params),
            headers: cfg.api.headers
        })
            .then((response) => {
                if (!response.ok) throw new Error('Ошибка сети при запросе к бэкенду');
                return response.json();
            })
            .then((data) => {
                if (data && data.data && data.data.paymentUrl) return data.data.paymentUrl;
                console.error('Бэкенд вернул некорректный ответ:', data);
                return '';
            })
            .catch((e) => {
                console.error('Ошибка в createPaymentAndGetUrl:', e);
                return '';
            });
    }

    createRedirectUrl(action) {
        const cfg = this.config;
        const url = new URL(cfg.redirect.baseUrl);

        url.searchParams.set('sandbox', cfg.redirect.sandbox);
        url.searchParams.set('type', cfg.redirect.type);
        url.searchParams.set('action', action);
        url.searchParams.set('orderID', this.orderId);
        url.searchParams.set('productId', cfg.product.id);

        return url.toString();
    }

    initSplitWidgets() {
        const containers = document.querySelectorAll(this.config.selectors.splitWidget);
        if (!containers.length) return;

        containers.forEach((container) => {
            this.initSplitWidget(container);
        });
    }

    initSplitWidget(container) {
        const cfg = this.config;

        let session = null;
        let lastAmount = '';

        const displayedAmount = () => {
            const tools = container.closest(cfg.selectors.elementTools);
            const price = tools ? tools.querySelector(cfg.selectors.price) : null;
            const text = (price && price.textContent) || '';
            const match = text.match(cfg.split.amountRegex);

            const parsed = parseFloat(
                (match ? match[1] : '')
                    .replace(/\s/g, '')
                    .replace(',', '.')
            );

            return Number.isFinite(parsed) && parsed > 0
                ? parsed.toFixed(2)
                : container.dataset[cfg.split.datasetAmountKey];
        };

        const mount = async () => {
            const amount = displayedAmount();

            if (!amount || amount === lastAmount) return;
            lastAmount = amount;

            if (session) {
                try {
                    session.destroy();
                } catch (e) {
                    /* ignore */
                }
                session = null;
            }

            container.innerHTML = '';

            session = await window.YaPay.createSession({
                version: cfg.version,
                currencyCode: this.resolveEnum(window.YaPay.CurrencyCode, cfg.currencyCode),
                merchantId: cfg.merchantId,
                totalAmount: amount,
                availablePaymentMethods: cfg.split.availablePaymentMethods
            }, {
                onPayButtonClick: function () {
                    return Promise.reject();
                }
            });

            session.mountWidget(container, {
                widgetType: this.resolveEnum(window.YaPay.WidgetType, cfg.widgets.split.widgetType),
                hasCheckoutButton: cfg.widgets.split.hasCheckoutButton
            });
        };

        mount().catch((error) => {
            console.warn('Yandex Split widget:', error);
        });

        const tools = container.closest(cfg.selectors.elementTools);
        const priceNode = tools ? tools.querySelector(cfg.selectors.price) : null;

        if (priceNode) {
            new MutationObserver(() => {
                setTimeout(() => {
                    mount().catch(() => {});
                }, cfg.split.mountDelayMs);
            }).observe(priceNode, cfg.split.observerOptions);
        }
    }
}

new YaPayIntegration(YA_PAY_CONFIG).init();

Описание функций

init() — точка входа. Загружает SDK, инициализирует кнопки и виджеты. Возвращает Promise<void>.

loadSdk() — грузит скрипт SDK один раз, повторные вызовы отдают сохранённый промис. Возвращает Promise.

resolveEnum(enumObject, key) — приводит строковое значение конфига к реальному значению перечисления из SDK. Если ключ не найден, возвращает исходную строку.

initPayButtons() — создаёт сессию оплаты и монтирует кнопку во все найденные контейнеры .ya--pay.

createPaymentAndGetUrl(formattedPrice) — отправляет данные на /api/ya/orders/ и возвращает ссылку на платёжную форму. При ошибке — пустую строку.

createRedirectUrl(action) — собирает URL возврата с параметрами успеха или ошибки.

initSplitWidgets() — ищет все контейнеры .yandex-pay--card-widget на странице и инициализирует каждый.

initSplitWidget(container) — монтирует виджет «Сплит» в конкретный контейнер и следит за изменением цены через MutationObserver.

Вёрстка на детальной странице

В шаблоне детальной страницы в месте вывода цены добавляем два контейнера.

<div id="elementTools">
    <span class="price--val"><?=$arResult["PRICE"]['DISCOUNT_PRICE']?> ₽</span>

    <div class="yandex-pay--card-widget"
         data-amount="<?=htmlspecialcharsbx(number_format($arResult['PRICE']['DISCOUNT_PRICE'], 2, '.', ''))?>"></div>

    <div class="ya--pay"></div>
</div>

Роутер: приём запроса

Роутер принимает запрос от клиента и перенаправляет его в API Yandex для создания платежа. Метод orders создаёт заказ и возвращает URL платёжной формы.

case 'ya':
    try {
        $arResult = [];

        if ($Api->getMethod() == 'orders') {
            $arResult = \Rest\Api\YaSplit::getOrderLink($data);

            if ($arResult['data']['paymentUrl']) {
                $_SESSION['paymentUrl'] = $arResult['data']['paymentUrl'];
            }
        }

        $result->setData((array)$arResult);
    } catch (\Exception $e) {
        $result->addError(new Error($e->getMessage()));
    }

    break;

Создание и финализация заказа

Класс YaSplit решает две задачи: получает ссылку на оплату у Yandex и создаёт заказ в Bitrix после возврата пользователя.

<?php namespace Rest\Api;

use Bitrix\Main\Context,
    Bitrix\Sale\PaySystem;

class YaSplit
{
    /**
     * Запрашивает у Yandex ссылку на оплату заказа.
     *
     * @param array $data Данные заказа из клиента (productId, price, action и т.д.)
     * @return array Ответ API: ['data' => ['paymentUrl' => string]]
     */
    public static function getOrderLink($data)
    {
        $http = new \Bitrix\Main\Web\HttpClient();

        $http->setTimeout(5);
        $http->setStreamTimeout(50);

        $apiKey = \Tichiy\Admin\Helpers\Option::getValue('ya--api-key');

        $http->setHeader('Authorization', 'Api-Key ' . $apiKey);
        $http->setHeader('Access-Control-Allow-Origin', '*');
        $http->setHeader('Content-Type', 'application/json', true);

        $json = $http->post(
            'https://sandbox.pay.yandex.ru/api/merchant/v1/orders',
            json_encode($data)
        );

        return \Bitrix\Main\Web\Json::decode($json);
    }

    /**
     * Создаёт заказ в Bitrix на основе данных, вернувшихся из Yandex.
     *
     * @param array $arData Данные редиректа (productId, price, orderID, action)
     * @return \Bitrix\Sale\Result|null Результат сохранения заказа или null
     */
    public static function createYaOrder($arData)
    {
        $paymentUrl = $_SESSION['paymentUrl'];

        unset($_SESSION['paymentUrl']);

        $user_id = $GLOBALS['USER']->GetID();

        if (empty($arData) || !$user_id) {
            return;
        }

        $DELIVERY_ID  = 2;
        $PAYMENT_ID   = 12;
        $QUANTITY     = 1;
        $productID    = \Rest\Helper\Clean::cleanInt($arData['productId']);
        $price        = \Rest\Helper\Clean::cleanInt($arData['price']);
        $formName     = 'Быстрая покупка Яндекс сплит';
        $currentUser  = \Bitrix\Main\Engine\CurrentUser::get();
        $phone        = \Rest\User\User::getPhoneByUserID($user_id);

        \Bitrix\Main\Loader::includeModule("sale");
        \Bitrix\Main\Loader::includeModule("catalog");

        $siteId = Context::getCurrent()->getSite();
        $currencyCode = \Bitrix\Currency\CurrencyManager::getBaseCurrency();

        $adress = 'Требуется уточнение';

        $order = \Bitrix\Sale\Order::create($siteId, $user_id);
        $order->setPersonTypeId(4);
        $order->setField('CURRENCY', $currencyCode);

        $message = 'Куплено из карточки товара с помощью Яндекс сплит. Номер заказа: ' . \Rest\Helper\Clean::cleanString($arData['orderID']);
        if ($paymentUrl) {
            $message .= PHP_EOL . PHP_EOL . 'Ссылка на оплату: ' . $paymentUrl;
        }

        $order->setField('USER_DESCRIPTION', $message);

        $newBasketItems = [];

        if ($productID > 0) {
            $basket = \Bitrix\Sale\Basket::create(SITE_ID);

            $item = $basket->createItem('catalog', $productID);
            $item->setFields([
                'QUANTITY' => $QUANTITY,
                'CURRENCY' => $currencyCode,
                'LID' => SITE_ID,
                'PRODUCT_PROVIDER_CLASS' => '\CCatalogProductProvider',
            ]);

            $newBasketItems[] = $item;

            $order->setBasket($basket);
        }

        $propertyCollection = $order->getPropertyCollection();

        foreach ($propertyCollection as $property) {
            if ($property->getField('CODE') == 'FORM_TEXT' && $formName) {
                $property->setValue($formName);
            }

            if ($property->getField('CODE') == 'BUY_1_CLICK') {
                $property->setValue('N');
            }

            if ($property->getField('CODE') == 'ADDRESS') {
                $property->setValue($adress);
            }

            if ($property->getField('CODE') == 'PHONE' && $phone) {
                $property->setValue($phone);
            }

            if ($currentUser->getFullName() && $property->getField('CODE') == 'FIO') {
                $property->setValue($currentUser->getFullName());
            }

            if ($property->getField('CODE') == 'EMAIL' && $currentUser->getEmail()) {
                $property->setValue($currentUser->getEmail());
            }
        }

        if ($phone) {
            $phoneProp = $propertyCollection->getPhone();
            if ($phoneProp) {
                $phoneProp->setValue($phone);
            }
        }

        if ($currentUser->getFullName()) {
            $nameProp = $propertyCollection->getPayerName();
            if ($nameProp) {
                $nameProp->setValue($currentUser->getFullName());
            }
        }

        if ($currentUser->getEmail()) {
            $emailPropValue = $propertyCollection->getUserEmail();
            if ($emailPropValue) {
                $emailPropValue->setValue($currentUser->getEmail());
            }
        }

        $addrPropValue = $propertyCollection->getItemByOrderPropertyId(99);
        if ($addrPropValue) {
            $addrPropValue->setValue('N');
        }

        $addrPropValue = $propertyCollection->getAddress();
        if ($addrPropValue) {
            $addrPropValue->setValue($adress);
        }

        $shipmentCollection = $order->getShipmentCollection();
        $shipment = $shipmentCollection->createItem(
            \Bitrix\Sale\Delivery\Services\Manager::getObjectById($DELIVERY_ID)
        );

        foreach ($shipmentCollection as $shipment) {
            if (!$shipment->isSystem()) {
                $shipmentItemCollection = $shipment->getShipmentItemCollection();
                foreach ($newBasketItems as $newBasketItem) {
                    $shipmentItem = $shipmentItemCollection->createItem($newBasketItem);
                    $shipmentItem->setQuantity($item->getQuantity());
                }
                break;
            }
        }

        $paymentCollection = $order->getPaymentCollection();
        $payment = $paymentCollection->createItem();
        $paySystemService = PaySystem\Manager::getObjectById($PAYMENT_ID);
        $payment->setFields([
            'PAY_SYSTEM_ID' => $paySystemService->getField("PAY_SYSTEM_ID"),
            'PAY_SYSTEM_NAME' => $paySystemService->getField("NAME"),
        ]);

        $order->setField('PRICE', $price);

        if ($arData['action'] == 'success') {
            $payment->setPaid("Y");
        }

        $order->doFinalAction(true);
        $result = $order->save();

        return $result;
    }
}

Обработчик возврата

Файл sale_ps_success.php принимает редирект от Yandex. Он читает параметры из GET, вызывает YaSplit::createYaOrder() и уводит пользователя на страницу благодарности или ошибки.

<?php
define("STOP_STATISTICS", true);
define('NO_AGENT_CHECK', true);
define('NOT_CHECK_PERMISSIONS', true);
define("DisableEventsCheck", true);
require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php");

if ($_REQUEST['type'] == 'yapay'){
    \Gas\Api\YaSplit::createYaOrder($_REQUEST);
}

$successPath = COption::GetOptionString("sale", "sale_ps_success_path", "/");
LocalRedirect($successPath);

require($_SERVER["DOCUMENT_ROOT"]."/bitrix/footer.php");

Итого

Клиент загружает SDK Yandex Pay, монтирует кнопку и виджет «Сплит», а сумму виджета подтягивает прямо из DOM — с MutationObserver на узле цены. Сервер валидирует запрос, создаёт платёж через API, а после возврата пользователя финализирует заказ в Bitrix. Ключевые точки отказа: пустой merchantId, отвалившийся SDK, недоступный эндпоинт API — все три логируются в консоль и не валят страницу целиком.

Если вы сделали все правильно. Настроили и подключили доступы, дописали роутер, то получите вполне готовый результат

Комментарии

Комментариев еще нет, Вы можете стать первым кто его оставит

Оставьте комментарий

На сайте используется система премодерирования комментариев, поэтому ваше сообщение будет опубликовано лишь после одобрения модератором

Вы отвечаете на комментарий пользователя

Отправить

ОБРАТНАЯ СВЯЗЬ

Напишите мне

Напишите мне, чтобы обсудить задачу. Работаю удаленно, официально (самозанятый), стоимость часа — 1800 ₽, в день могу уделять проекту 3–4 часа. Отвечаю быстро, консультация бесплатная.

Во время отправки произошла ошибка, пожалуйста попробуйте еще раз через некоторое время
Сообщение отправлено успешно

Телефоны

+7(993) 007-18-96

Email

info@tichiy.ru

Адрес

Россия, г. Москва

Отправляя форму Вы автоматически подтверждаете, что ознакомились и принимаете Политику конфиденциальности сайта