Вывод виджета и кнопки оплаты Яндекс Пэй на детальной странице товара
Последние записи
Хостинг для сайта
Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.
Перейти Партнёрская ссылка. Для вас цена не меняется.Задача
Нужно вывести на детальной странице товара две сущности Yandex Pay: кнопку оплаты и виджет «Сплит». Оплата инициируется на клиенте, серверная часть создаёт заказ и возвращает ссылку на платёжную форму. После оплаты Yandex редиректит пользователя обратно на сайт, где финализируется заказ в Bitrix.
Схема работы:
- На детальной странице рендерятся контейнеры под кнопку и виджет.
JS-скрипт подгружаетSDKYandex Payи монтирует в них виджеты.- По клику на кнопку уходит запрос на бэкенд, тот валидирует данные по каталогу, создаёт платёж через
APIYandexи возвращает ссылку. - Пользователь уходит на оплату, после чего
Yandexредиректит его наsale_ps_success.php. - Обработчик возврата проверяет реальный статус платежа через
APIи финализирует заказ вBitrix.
Подготовка
До начала работы нужны три вещи на стороне Bitrix.
HL-блок YapayOrders
Создаём highload-блок с именем YapayOrders и полями:
UF_ORDER_ID— строка, уникальный индекс;UF_USER_ID— число;UF_PAYMENT_URL— строка;UF_PRICE— число;UF_PRODUCT_ID— число;UF_STATUS— строка (pending,completed,failed);UF_CREATED_AT— дата/время.
Уникальный индекс на UF_ORDER_ID — это не украшение. Без него идемпотентность работает только на честном слове: два параллельных редиректа от Yandex спокойно пройдут проверку и создадут два заказа.
Свойство заказа YAPAY_ORDER_ID
В настройках интернет-магазина добавляем свойство заказа с символьным кодом YAPAY_ORDER_ID, тип «Строка». Оно нужно, чтобы находить уже созданный заказ по номеру Yandex при повторных вызовах success-URL. Без уникального индекса на HL-блоке и без этого свойства система не отличит новый заказ от повторного.
Настройки в опциях модуля
Идентификаторы платёжной системы, службы доставки, типа плательщика и свойства «Адрес» — не константы. Их нужно вынести в опции модуля, а не хардкодить в классе: на разных проектах они разные, и правки должны делаться без залезания в код.
ya--env — sandbox|production
ya--api-key — API-ключ мерчанта
ya--merchant-id — merchantId из кабинета Yandex
ya--payment-id — ID платёжной системы Yandex Split
ya--delivery-id — ID службы доставки по умолчанию
ya--person-type-id — ID типа плательщика
ya--address-prop — ID свойства «Адрес»
ya--address-stub — заглушка адреса, например «Требуется уточнение»
Клиентский скрипт
Конфиг и класс интеграции живут в одном файле — так проще править окружение и не расползаться по коду при изменении селекторов. Ссылку возврата можно указать прямо в конфигурации загрузчика Yandex — параметр baseUrl в блоке redirect. Лучше всего подключать этот файл в component_epilog.php детального шаблона или в script.js шаблона сайта.
const YA_PAY_CONFIG = {
merchantId: '<?=CUtil::JSEscape($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);
this.observers = [];
}
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 = () => {
if (!window.YaPay) {
reject(new Error('SDK loaded but window.YaPay is undefined'));
return;
}
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
return window[promiseKey];
}
resolveEnum(enumObject, key) {
if (!enumObject || typeof enumObject !== 'object' || 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;
}
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)
});
} 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);
});
window.addEventListener('beforeunload', () => this.destroyObservers(), { once: true });
}
destroyObservers() {
this.observers.forEach((observer) => {
try {
observer.disconnect();
} catch (e) {
/* ignore */
}
});
this.observers = [];
}
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 () => {
if (!document.body.contains(container)) {
return;
}
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();
}
});
if (!document.body.contains(container)) {
try {
session.destroy();
} catch (e) {
/* ignore */
}
return;
}
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) {
const observer = new MutationObserver(() => {
if (!document.body.contains(container)) {
observer.disconnect();
return;
}
setTimeout(() => {
mount().catch(() => {});
}, cfg.split.mountDelayMs);
});
observer.observe(priceNode, cfg.split.observerOptions);
this.observers.push(observer);
}
}
}
new YaPayIntegration(YA_PAY_CONFIG).init();
Описание функций
init() — точка входа. Загружает SDK, инициализирует кнопки и виджеты. Возвращает Promise<void>.
loadSdk() — грузит скрипт SDK один раз. После onload проверяет, что window.YaPay действительно определён — иначе промис реджектится, и вызывающий код получает внятную ошибку вместо падения на следующем шаге.
resolveEnum(enumObject, key) — приводит строковое значение конфига к реальному значению перечисления из SDK. Если enumObject не объект или ключ не найден — возвращает исходную строку.
initPayButtons() — создаёт сессию оплаты и монтирует кнопку во все найденные контейнеры .ya--pay.
createPaymentAndGetUrl(formattedPrice) — отправляет данные на /api/ya/orders/ и возвращает ссылку на платёжную форму. При ошибке — пустую строку.
createRedirectUrl(action) — собирает URL возврата с параметрами. Цену сюда больше не добавляем: сервер всё равно возьмёт её из каталога, а не из GET.
initSplitWidgets() — ищет все контейнеры .yandex-pay--card-widget и инициализирует каждый. При выгрузке страницы отключает все наблюдатели.
destroyObservers() — проходит по массиву сохранённых MutationObserver и отключает их. Вызывается на beforeunload, чтобы не оставлять мёртвых наблюдателей.
initSplitWidget(container) — монтирует виджет «Сплит» в контейнер и следит за изменением цены. Наблюдатель сохраняется в this.observers и отключается сам, если контейнер исчез из DOM — иначе на SPA-переходах накапливались бы висячие коллбэки.
Вёрстка на детальной странице
В шаблоне детальной страницы в месте вывода цены добавляем два контейнера.
<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>
Роутер: приём запроса
Роутер принимает запрос от клиента, проверяет сессию Bitrix, валидирует данные по каталогу и только потом уходит в API Yandex. Три обязательные проверки: check_bitrix_sessid(), чтобы отсечь CSRF с чужого сайта; сверка productId и цены с реальным каталогом, чтобы пользователь не мог подделать сумму; проверка обязательных полей запроса.
<?php
namespace Rest\Api;
use Bitrix\Main\Error;
use Bitrix\Main\Context;
use Rest\Api\Response;
use Rest\Api\Router;
$api = new Router();
$result = new Response();
$resource = $api->getResource();
$method = $api->getMethod();
$data = $api->getJsonBody();
switch ($resource) {
case 'ya':
try {
if (!check_bitrix_sessid()) {
throw new \Bitrix\Main\AccessDeniedException('Session expired');
}
$arResult = [];
if ($method === 'orders') {
$validation = YaSplit::validateOrderData($data);
if (!$validation->isSuccess()) {
throw new \Bitrix\Main\ArgumentException('Invalid order data');
}
$catalogCheck = YaSplit::validateAgainstCatalog($data);
if (!$catalogCheck->isSuccess()) {
throw new \Bitrix\Main\ArgumentException('Product or price mismatch');
}
$arResult = YaSplit::getOrderLink($data);
if (!empty($arResult['data']['paymentUrl'])) {
YaSplit::saveOrderData(
(string)$data['orderId'],
[
'userId' => (int)\Bitrix\Main\Engine\CurrentUser::get()->getId(),
'paymentUrl' => (string)$arResult['data']['paymentUrl'],
'price' => (float)$data['totalAmount'],
'productId' => (int)$data['cart']['items'][0]['productId'],
]
);
}
}
$result->setData((array)$arResult);
} catch (\Exception $e) {
$result->addError(new Error($e->getMessage()));
}
break;
}
$result->send();
Создание и финализация заказа
Класс YaSplit решает несколько задач: валидирует данные по каталогу Bitrix, получает ссылку на оплату у Yandex, проверяет реальный статус платежа, хранит связку orderId → данные в HL-блоке и создаёт заказ в Bitrix после возврата пользователя.
Идемпотентность держится на двух опорах: уникальный индекс на UF_ORDER_ID в HL-блоке и проверка существующего заказа по свойству YAPAY_ORDER_ID. Обе защиты нужны: индекс ловит гонки на уровне базы, свойство — на уровне бизнес-логики.
<?php
namespace Rest\Api;
use Bitrix\Currency\CurrencyManager;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Context;
use Bitrix\Main\Engine\CurrentUser;
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;
use Bitrix\Sale\Basket;
use Bitrix\Sale\Delivery\Services\Manager as DeliveryManager;
use Bitrix\Sale\Internals\OrderPropsValueTable;
use Bitrix\Sale\Order;
use Bitrix\Sale\PaySystem;
use Rest\Helper\Clean;
use Rest\User\User;
class YaSplit
{
private const HL_BLOCK_NAME = 'YapayOrders';
private const PROP_ORDER_ID = 'YAPAY_ORDER_ID';
private const STATUS_PENDING = 'pending';
private const STATUS_COMPLETED = 'completed';
private const STATUS_FAILED = 'failed';
private static ?string $hlDataClass = null;
/**
* Возвращает базовый URL API Yandex в зависимости от окружения.
*
* @return string
*/
private static function getApiBase(): string
{
$env = \Tichiy\Admin\Helpers\Option::getValue('ya--env') ?: 'sandbox';
return $env === 'production'
? 'https://pay.yandex.ru/api/merchant/v1'
: 'https://sandbox.pay.yandex.ru/api/merchant/v1';
}
/**
* Возвращает API-ключ мерчанта из настроек модуля.
*
* @return string
*/
private static function getApiKey(): string
{
return (string)\Tichiy\Admin\Helpers\Option::getValue('ya--api-key');
}
/**
* Возвращает ID платёжной системы Yandex Split из опций.
*
* @return int
*/
private static function getPaymentId(): int
{
return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--payment-id');
}
/**
* Возвращает ID службы доставки по умолчанию из опций.
*
* @return int
*/
private static function getDeliveryId(): int
{
return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--delivery-id');
}
/**
* Возвращает ID типа плательщика из опций.
*
* @return int
*/
private static function getPersonTypeId(): int
{
return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--person-type-id');
}
/**
* Возвращает ID свойства «Адрес» из опций.
*
* @return int
*/
private static function getAddressPropId(): int
{
return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--address-prop');
}
/**
* Возвращает заглушку адреса из опций.
*
* @return string
*/
private static function getAddressStub(): string
{
return (string)\Tichiy\Admin\Helpers\Option::getValue('ya--address-stub') ?: 'Требуется уточнение';
}
/**
* Проверяет обязательные поля запроса на создание заказа.
*
* @param array $data Данные запроса от клиента
* @return \Bitrix\Main\Result
*/
public static function validateOrderData(array $data): \Bitrix\Main\Result
{
$result = new \Bitrix\Main\Result();
if (empty($data['orderId'])) {
$result->addError(new \Bitrix\Main\Error('orderId is required'));
}
if (empty($data['cart']['items']) || !is_array($data['cart']['items'])) {
$result->addError(new \Bitrix\Main\Error('cart.items is required'));
}
$productId = (int)($data['cart']['items'][0]['productId'] ?? 0);
if ($productId <= 0) {
$result->addError(new \Bitrix\Main\Error('productId is required'));
}
if (empty($data['totalAmount']) || (float)$data['totalAmount'] <= 0) {
$result->addError(new \Bitrix\Main\Error('totalAmount must be positive'));
}
return $result;
}
/**
* Сверяет цену и товар из запроса с реальным каталогом Bitrix.
* Защищает от подделки totalAmount и productId на клиенте.
*
* @param array $data Данные запроса от клиента
* @return \Bitrix\Main\Result
*/
public static function validateAgainstCatalog(array $data): \Bitrix\Main\Result
{
$result = new \Bitrix\Main\Result();
Loader::includeModule('iblock');
Loader::includeModule('catalog');
$productId = (int)$data['cart']['items'][0]['productId'];
$clientPrice = round((float)$data['totalAmount'], 2);
$product = \CCatalogProduct::GetByID($productId);
if (!$product) {
$result->addError(new \Bitrix\Main\Error('Product not found'));
return $result;
}
$basePriceRow = \CPrice::GetBasePrice($productId);
if (!$basePriceRow) {
$result->addError(new \Bitrix\Main\Error('Base price not found'));
return $result;
}
$serverPrice = round((float)$basePriceRow['PRICE'], 2);
if (abs($serverPrice - $clientPrice) > 0.01) {
$result->addError(new \Bitrix\Main\Error('Price mismatch'));
}
return $result;
}
/**
* Запрашивает у Yandex ссылку на оплату заказа.
*
* @param array $data Данные заказа из клиента
* @return array Ответ API: ['data' => ['paymentUrl' => string]]
*/
public static function getOrderLink(array $data): array
{
$http = new HttpClient();
$http->setTimeout(5);
$http->setStreamTimeout(50);
$http->setHeader('Authorization', 'Api-Key ' . self::getApiKey());
$http->setHeader('Content-Type', 'application/json', true);
$json = $http->post(
self::getApiBase() . '/orders',
Json::encode($data)
);
return Json::decode($json) ?: [];
}
/**
* Проверяет реальный статус платежа через API Yandex.
* Параметр action из GET подделать легко, поэтому оплата
* подтверждается только ответом самого Yandex.
*
* @param string $orderId Номер заказа в Yandex
* @return bool true, если платёж действительно проведён
*/
public static function checkOrderStatus(string $orderId): bool
{
$http = new HttpClient();
$http->setTimeout(5);
$http->setHeader('Authorization', 'Api-Key ' . self::getApiKey());
$http->setHeader('Content-Type', 'application/json');
$url = self::getApiBase() . '/orders/' . urlencode($orderId);
$json = $http->get($url);
$data = Json::decode($json) ?: [];
$status = $data['data']['status'] ?? '';
return in_array($status, ['CAPTURED', 'SUCCESS', 'COMPLETED', 'PAID'], true);
}
/**
* Сохраняет данные о созданном платеже в HL-блок.
* Уникальный индекс на UF_ORDER_ID отсекает повторные вставки.
*
* @param string $orderId Идентификатор заказа Yandex
* @param array $data Данные платежа (userId, paymentUrl, price, productId)
* @return void
*/
public static function saveOrderData(string $orderId, array $data): void
{
$dataClass = self::getHlDataClass();
if ($dataClass === null) {
self::log('HL block YapayOrders not found');
return;
}
try {
$dataClass::add([
'UF_ORDER_ID' => $orderId,
'UF_USER_ID' => (int)$data['userId'],
'UF_PAYMENT_URL' => (string)$data['paymentUrl'],
'UF_PRICE' => (float)$data['price'],
'UF_PRODUCT_ID' => (int)$data['productId'],
'UF_STATUS' => self::STATUS_PENDING,
'UF_CREATED_AT' => new DateTime(),
]);
} catch (\Exception $e) {
// Уникальный конфликт — запись уже есть, ничего страшного.
// Прочие ошибки логируем: платёж в Yandex создан, но paymentUrl не сохранён.
self::log('Failed to save payment data: ' . $e->getMessage());
}
}
/**
* Достаёт сохранённую запись о платеже из HL-блока.
*
* @param string $orderId Идентификатор заказа Yandex
* @return array|null
*/
public static function getPaymentRecord(string $orderId): ?array
{
$dataClass = self::getHlDataClass();
if ($dataClass === null) {
return null;
}
$row = $dataClass::getList([
'filter' => ['=UF_ORDER_ID' => $orderId],
'limit' => 1,
])->fetch();
return $row ?: null;
}
/**
* Обновляет статус записи в HL-блоке.
*
* @param string $orderId Идентификатор заказа Yandex
* @param string $status Новый статус
* @return void
*/
private static function updateStatus(string $orderId, string $status): void
{
$dataClass = self::getHlDataClass();
if ($dataClass === null) {
return;
}
$row = $dataClass::getList([
'filter' => ['=UF_ORDER_ID' => $orderId],
'select' => ['ID'],
'limit' => 1,
])->fetch();
if ($row) {
$dataClass::update((int)$row['ID'], ['UF_STATUS' => $status]);
}
}
/**
* Возвращает класс данных HL-блока с кешированием.
* compileEntity() не бесплатна, а вызывается на каждом запросе.
*
* @return string|null
*/
private static function getHlDataClass(): ?string
{
if (self::$hlDataClass !== null) {
return self::$hlDataClass === '' ? null : self::$hlDataClass;
}
Loader::includeModule('highloadblock');
$hl = HighloadBlockTable::getList([
'filter' => ['=NAME' => self::HL_BLOCK_NAME],
'limit' => 1,
])->fetch();
if (!$hl) {
self::$hlDataClass = '';
return null;
}
self::$hlDataClass = HighloadBlockTable::compileEntity($hl)->getDataClass();
return self::$hlDataClass;
}
/**
* Ищет ранее созданный заказ Bitrix по номеру Yandex.
*
* @param string $orderId Идентификатор заказа Yandex
* @return int ID найденного заказа или 0
*/
private static function findExistingOrder(string $orderId): int
{
$row = OrderPropsValueTable::getList([
'filter' => [
'=CODE' => self::PROP_ORDER_ID,
'=VALUE' => $orderId,
],
'select' => ['ORDER_ID'],
'limit' => 1,
])->fetch();
return $row ? (int)$row['ORDER_ID'] : 0;
}
/**
* Логирует сообщение в файл. Используется для недосозданных заказов
* и прочих ситуаций, которые требуют ручного разбора.
*
* @param string $message Сообщение
* @return void
*/
private static function log(string $message): void
{
$logPath = $_SERVER['DOCUMENT_ROOT'] . '/local/logs/yapay.log';
$dir = dirname($logPath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents(
$logPath,
'[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL,
FILE_APPEND
);
}
/**
* Создаёт заказ в Bitrix на основе данных из HL-блока.
* Повторный вызов с тем же orderId дубль не создаёт.
*
* @param array $arData Данные редиректа (orderID, action)
* @return void
*/
public static function createYaOrder(array $arData): void
{
$orderId = (string)($arData['orderID'] ?? '');
$currentUserId = (int)$GLOBALS['USER']->GetID();
if ($orderId === '' || $currentUserId <= 0) {
return;
}
$record = self::getPaymentRecord($orderId);
if (!$record) {
self::log('Payment record not found for orderId=' . $orderId);
return;
}
// Заказ уже создан — повторный вызов success-URL ничего не делает
if (self::findExistingOrder($orderId) > 0) {
return;
}
// Платёж был создан другим пользователем — не даём создать заказ в своём аккаунте
if ((int)$record['UF_USER_ID'] !== $currentUserId) {
self::log('User mismatch for orderId=' . $orderId);
return;
}
$productId = (int)$record['UF_PRODUCT_ID'];
$price = (float)$record['UF_PRICE'];
if ($productId <= 0 || $price <= 0) {
return;
}
$isPaid = ($arData['action'] ?? '') === 'success' && self::checkOrderStatus($orderId);
$paymentId = self::getPaymentId();
$paySystemService = PaySystem\Manager::getObjectById($paymentId);
if (!$paySystemService) {
self::log('Pay system not found: ' . $paymentId);
return;
}
$deliveryId = self::getDeliveryId();
$personTypeId = self::getPersonTypeId();
$addressPropId = self::getAddressPropId();
$address = self::getAddressStub();
$formName = 'Быстрая покупка Яндекс сплит';
$quantity = 1;
$currentUser = CurrentUser::get();
$phone = User::getPhoneByUserID($currentUserId);
Loader::includeModule('sale');
Loader::includeModule('catalog');
$siteId = Context::getCurrent()->getSite();
$currencyCode = CurrencyManager::getBaseCurrency();
try {
$order = Order::create($siteId, $currentUserId);
$order->setPersonTypeId($personTypeId);
$order->setField('CURRENCY', $currencyCode);
$message = 'Куплено из карточки товара с помощью Яндекс сплит. Номер заказа: ' . Clean::cleanString($orderId);
if (!empty($record['UF_PAYMENT_URL'])) {
$message .= PHP_EOL . PHP_EOL . 'Ссылка на оплату: ' . $record['UF_PAYMENT_URL'];
}
$order->setField('USER_DESCRIPTION', $message);
$basket = Basket::create($siteId);
$item = $basket->createItem('catalog', $productId);
$item->setFields([
'QUANTITY' => $quantity,
'CURRENCY' => $currencyCode,
'LID' => $siteId,
'PRODUCT_PROVIDER_CLASS' => '\CCatalogProductProvider',
]);
$order->setBasket($basket);
$propertyCollection = $order->getPropertyCollection();
foreach ($propertyCollection as $property) {
switch ($property->getField('CODE')) {
case 'FORM_TEXT':
$property->setValue($formName);
break;
case 'BUY_1_CLICK':
$property->setValue('N');
break;
case 'ADDRESS':
$property->setValue($address);
break;
case 'PHONE':
if ($phone) {
$property->setValue($phone);
}
break;
case 'FIO':
if ($currentUser->getFullName()) {
$property->setValue($currentUser->getFullName());
}
break;
case 'EMAIL':
if ($currentUser->getEmail()) {
$property->setValue($currentUser->getEmail());
}
break;
case self::PROP_ORDER_ID:
$property->setValue($orderId);
break;
}
}
if ($phone && ($phoneProp = $propertyCollection->getPhone())) {
$phoneProp->setValue($phone);
}
if ($currentUser->getFullName() && ($nameProp = $propertyCollection->getPayerName())) {
$nameProp->setValue($currentUser->getFullName());
}
if ($currentUser->getEmail() && ($emailProp = $propertyCollection->getUserEmail())) {
$emailProp->setValue($currentUser->getEmail());
}
if ($addressPropId > 0 && ($addrPropValue = $propertyCollection->getItemByOrderPropertyId($addressPropId))) {
$addrPropValue->setValue('N');
}
if ($addrPropValue = $propertyCollection->getAddress()) {
$addrPropValue->setValue($address);
}
$shipmentCollection = $order->getShipmentCollection();
$deliveryService = DeliveryManager::getObjectById($deliveryId);
if ($deliveryService) {
$shipmentCollection->createItem($deliveryService);
foreach ($shipmentCollection as $shipment) {
if ($shipment->isSystem()) {
continue;
}
$shipmentItemCollection = $shipment->getShipmentItemCollection();
$shipmentItem = $shipmentItemCollection->createItem($item);
$shipmentItem->setQuantity($item->getQuantity());
break;
}
}
$paymentCollection = $order->getPaymentCollection();
$payment = $paymentCollection->createItem();
$payment->setFields([
'PAY_SYSTEM_ID' => $paySystemService->getField('PAY_SYSTEM_ID'),
'PAY_SYSTEM_NAME' => $paySystemService->getField('NAME'),
]);
$order->setField('PRICE', $price);
if ($isPaid) {
$payment->setPaid('Y');
}
$order->doFinalAction(true);
$saveResult = $order->save();
if (!$saveResult->isSuccess()) {
self::log('Order save failed: ' . implode(', ', $saveResult->getErrorMessages()));
self::updateStatus($orderId, self::STATUS_FAILED);
return;
}
self::updateStatus($orderId, self::STATUS_COMPLETED);
} catch (\Exception $e) {
// Деньги списаны, заказ не создан — помечаем и логируем.
// Разбор — вручную, по записи в HL-блоке со статусом failed.
self::updateStatus($orderId, self::STATUS_FAILED);
self::log('Order creation failed for orderId=' . $orderId . ': ' . $e->getMessage());
}
}
}
Как разбирать недосозданные заказы
Если оплата CAPTURED, а заказ в Bitrix не создался — запись в HL-блоке остаётся со статусом failed, а детали падения уходят в лог /local/logs/yapay.log. Разбирается это вручную: находим запись по UF_ORDER_ID, проверяем платёж в кабинете Yandex, при необходимости создаём заказ через админку и меняем статус на completed. Автоматического retry в примере нет — он должен быть отдельной задачей с идемпотентной очередью.
Обработчик возврата
Файл sale_ps_success.php принимает редирект от Yandex. Он читает параметры из GET, вызывает YaSplit::createYaOrder() и уводит пользователя на страницу благодарности. Никакого header.php здесь быть не должно: он выводит HTML и отправляет заголовки, а попытка сделать LocalRedirect() после этого превратится в «headers already sent». Подключаем только prolog_before.php.
<?php
define('STOP_STATISTICS', true);
define('NO_AGENT_CHECK', true);
define('NOT_CHECK_PERMISSIONS', true);
define('DisableEventsCheck', true);
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
if (($_REQUEST['type'] ?? '') === 'yapay') {
\Rest\Api\YaSplit::createYaOrder($_REQUEST);
}
$successPath = COption::GetOptionString('sale', 'sale_ps_success_path', '/');
LocalRedirect($successPath);
Чеки по 54-ФЗ
Оплата через Yandex Pay не освобождает от фискализации. Чек нужно отправлять в момент расчёта, а расчёт происходит тогда, когда payment->setPaid('Y') доходит до состояния «оплачено». Штатный механизм Bitrix — привязка кассы к платёжной системе в настройках PaySystem. Если используется внешний сервис фискализации, подключаемся к событию OnSalePaymentEntitySaved и отправляем чек из обработчика, передавая сумму платежа, наименование товара и признак способа расчёта (полный расчёт или оплата в кредит — для «Сплита» это как раз второй вариант).
Без фискализации система работает, но первый же чек из налоговой прилетит раньше, чем вы об этом подумаете.
Тестирование
Три сценария, которые стоит проверить до выкладки на прод.
Подделка action=success
Берём созданный orderId из HL-блока, но не оплачиваем. Открываем https://domain.ru/bitrix/tools/sale_ps_success.php?type=yapay&action=success&orderID=XXX вручную. Ожидаемое поведение: checkOrderStatus() обращается к API Yandex, получает статус PENDING, заказ создаётся, но setPaid('Y') не вызывается. Если заказ помечается оплаченным — checkOrderStatus() работает неправильно.
Повторный вызов success-URL
Сразу после первого успешного возврата открываем тот же URL второй раз. Ожидаемое поведение: findExistingOrder() находит уже созданный заказ, второй раз ничего не создаётся. Если в списке заказов появился дубль — уникальный индекс на UF_ORDER_ID не создан, либо findExistingOrder() не работает.
Мок API Yandex
Для локальных прогонов удобно замокать getApiBase() — например, добавить в опции ya--api-base, и в тестах указывать туда адрес локального мока. Мок отвечает на POST /orders фейковым paymentUrl, а на GET /orders/{id} — статусом CAPTURED или PENDING в зависимости от сценария. Так проверяются все ветки createYaOrder() без реальных списаний.
Итого
Клиент загружает SDK Yandex Pay, монтирует кнопку и виджет «Сплит», а сумму виджета подтягивает прямо из DOM — с MutationObserver, который корректно отключается при удалении контейнера. Сервер проверяет сессию Bitrix, сверяет цену и товар с каталогом, создаёт платёж через API и сохраняет связку orderId → данные платежа в HL-блок с уникальным индексом. При возврате пользователя обработчик проверяет реальный статус платежа через API Yandex, сверяет пользователя и создаёт заказ только один раз — повторные вызовы success-URL дубль не сделают. Если заказ не удалось сохранить, запись остаётся в статусе failed и попадает в лог: деньги списаны, заказ разбирается вручную. Ключевые точки отказа — недоступный API, отсутствующая касса 54-ФЗ, неверные ID платёжной системы и доставки — все конфигурируемы через опции и не валят страницу целиком.
Комментарии