Вывод виджета и кнопки оплаты Яндекс Пэй на детальной странице товара
Последние записи
Хостинг для сайта
Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.
Перейти Партнёрская ссылка. Для вас цена не меняется.Задача
Нужно вывести на детальной странице товара две сущности Yandex Pay: кнопку оплаты и виджет «Сплит». Оплата инициируется на клиенте, серверная часть создаёт заказ и возвращает ссылку на платёжную форму. После оплаты Yandex редиректит пользователя обратно на сайт, где финализируется заказ в Bitrix.
Схема работы:
- На детальной странице рендерятся контейнеры под кнопку и виджет.
JS-скрипт подгружаетSDKYandex Payи монтирует в них виджеты.- По клику на кнопку уходит запрос на бэкенд, тот создаёт платёж через
APIYandexи возвращает ссылку. - Пользователь уходит на оплату, после чего
Yandexредиректит его наsale_ps_success.php. - Тот по сессионной ссылке и данным из редиректа создаёт заказ в
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 — все три логируются в консоль и не валят страницу целиком.
Если вы сделали все правильно. Настроили и подключили доступы, дописали роутер, то получите вполне готовый результат 
Комментарии