Displaying the Yandex Pay widget and payment button on the product detail page
Rus
Eng
Вывод виджета и кнопки оплаты Яндекс Пэй на детальной странице товара
I solve complex technical challenges for Bitrix and 1C—ranging from auditing existing code to website development and configuring complex integrations—with a guarantee of results.
Recommended

Website hosting

If you are choosing hosting for your project, I recommend Timeweb. Modern infrastructure, a wide range of plans from starter options to high-load VPS, and responsive tech support that actually helps instead of sending template replies. I host some of my own projects there and recommend it to my clients.

Go to site Affiliate link. The price remains the same for you.

All English-language materials have been translated fully automatically using the Google service

The Task

We need to render two Yandex Pay entities on the product detail page: a payment button and the "Split" widget. Payment is initiated on the client, the server side creates an order and returns a link to the payment form. After payment, Yandex redirects the user back to the site, where the order is finalized in Bitrix.

The flow:

  1. Containers for the button and widget are rendered on the detail page.
  2. A JS script loads the Yandex Pay SDK and mounts the widgets.
  3. Clicking the button calls the backend, which validates the data against the catalog, creates a payment via the Yandex API, and returns a link.
  4. The user pays, after which Yandex redirects them to sale_ps_success.php.
  5. The return handler checks the actual payment status via the API and finalizes the order in Bitrix.

Preparation

Three things need to be in place on the Bitrix side before you start.

HL Block YapayOrders

Create a highload block named YapayOrders with fields:

  • UF_ORDER_ID — string, unique index;
  • UF_USER_ID — number;
  • UF_PAYMENT_URL — string;
  • UF_PRICE — number;
  • UF_PRODUCT_ID — number;
  • UF_STATUS — string (pending, completed, failed);
  • UF_CREATED_AT — datetime.

The unique index on UF_ORDER_ID isn't decoration. Without it idempotency works on the honor system: two parallel redirects from Yandex happily pass the check and create two orders.

The YAPAY_ORDER_ID Order Property

Add an order property with symbolic code YAPAY_ORDER_ID (string) in the online store settings. It's used to find an already-created order by the Yandex order number on repeated hits to the success URL. Without the unique index on the HL block and without this property, the system can't tell a new order from a repeat.

Module Option Settings

Payment system ID, delivery service ID, payer type ID, and the "Address" property ID are not constants. Put them into module options instead of hardcoding them in the class: they differ between projects, and edits should happen without touching code.

ya--env            — sandbox|production
ya--api-key        — merchant API key
ya--merchant-id    — merchantId from the Yandex dashboard
ya--payment-id     — Yandex Split payment system ID
ya--delivery-id    — default delivery service ID
ya--person-type-id — payer type ID
ya--address-prop   — "Address" property ID
ya--address-stub   — address stub, e.g. "Requires clarification"

The Client Script

The config and the integration class live in one file — easier to tune the environment and it doesn't sprawl when selectors change. The return URL can be set right in the Yandex loader config — the baseUrl parameter inside the redirect block. Best to include this file in component_epilog.php of the detail template or in the site template's 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: 'Payment for gas analysis equipment on 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('Failed to load 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('Yandex Pay form error: ' + reason);
            }
        }).then((paymentSession) => {
            const targets = document.querySelectorAll(cfg.selectors.payButton);

            if (!targets.length) {
                console.warn('No .ya--pay containers found — skipping.');
                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 error for #' + 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('Network error while calling the backend');
                return response.json();
            })
            .then((data) => {
                if (data && data.data && data.data.paymentUrl) return data.data.paymentUrl;
                console.error('Backend returned an unexpected response:', data);
                return '';
            })
            .catch((e) => {
                console.error('Error in 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();

Function Reference

init() — entry point. Loads the SDK, initializes buttons and widgets. Returns Promise<void>.

loadSdk() — loads the SDK script once. After onload it verifies that window.YaPay is actually defined — otherwise the promise rejects, and the caller gets a clear error instead of crashing on the next step.

resolveEnum(enumObject, key) — maps a config string to the actual enum value from the SDK. If enumObject isn't an object or the key isn't found, returns the original string.

initPayButtons() — creates a payment session and mounts the button into every .ya--pay container found.

createPaymentAndGetUrl(formattedPrice) — posts the data to /api/ya/orders/ and returns the payment form URL. Returns an empty string on error.

createRedirectUrl(action) — builds the return URL. Price is no longer added here: the server will read it from the catalog anyway, not from GET.

initSplitWidgets() — finds every .yandex-pay--card-widget container and initializes each. Detaches all observers on page unload.

destroyObservers() — loops through the stored MutationObserver instances and disconnects them. Called on beforeunload so no observers are left dangling.

initSplitWidget(container) — mounts the "Split" widget into the container and watches for price changes. The observer is stored in this.observers and disconnects itself when the container is removed from the DOM — otherwise SPA transitions would leak dangling callbacks.

Markup on the Detail Page

In the detail page template, where the price is output, we add two containers.

<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>

Router: Accepting the Request

The router takes the request, validates the Bitrix session, validates the data against the catalog, and only then calls the Yandex API. Three mandatory checks: check_bitrix_sessid() to cut off CSRF from other sites; comparison of productId and price against the real catalog so the user can't forge the amount; validation of required request fields.

<?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();

Creating and Finalizing the Order

The YaSplit class does several things: validates data against the Bitrix catalog, gets the payment link from Yandex, verifies the real payment status, stores the orderId → payment data pair in an HL block, and creates the order in Bitrix when the user returns.

Idempotency stands on two legs: a unique index on UF_ORDER_ID in the HL block, and a lookup of the existing order by the YAPAY_ORDER_ID property. Both defenses are needed: the index catches races at the database level, the property at the business logic level.

<?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;

    /**
     * Returns the Yandex API base URL depending on the environment.
     *
     * @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';
    }

    /**
     * Returns the merchant API key from module settings.
     *
     * @return string
     */
    private static function getApiKey(): string
    {
        return (string)\Tichiy\Admin\Helpers\Option::getValue('ya--api-key');
    }

    /**
     * Returns the Yandex Split payment system ID from options.
     *
     * @return int
     */
    private static function getPaymentId(): int
    {
        return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--payment-id');
    }

    /**
     * Returns the default delivery service ID from options.
     *
     * @return int
     */
    private static function getDeliveryId(): int
    {
        return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--delivery-id');
    }

    /**
     * Returns the payer type ID from options.
     *
     * @return int
     */
    private static function getPersonTypeId(): int
    {
        return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--person-type-id');
    }

    /**
     * Returns the "Address" property ID from options.
     *
     * @return int
     */
    private static function getAddressPropId(): int
    {
        return (int)\Tichiy\Admin\Helpers\Option::getValue('ya--address-prop');
    }

    /**
     * Returns the address stub from options.
     *
     * @return string
     */
    private static function getAddressStub(): string
    {
        return (string)\Tichiy\Admin\Helpers\Option::getValue('ya--address-stub') ?: 'Requires clarification';
    }

    /**
     * Validates the required fields of the create-order request.
     *
     * @param array $data Request data from the client
     * @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;
    }

    /**
     * Compares the price and product from the request against the real Bitrix catalog.
     * Protects against forged totalAmount and productId on the client.
     *
     * @param array $data Request data from the client
     * @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;
    }

    /**
     * Requests a payment link for the order from Yandex.
     *
     * @param array $data Order data from the client
     * @return array API response: ['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) ?: [];
    }

    /**
     * Checks the real payment status via the Yandex API.
     * The action GET parameter is easy to forge, so payment is
     * confirmed only by Yandex itself.
     *
     * @param string $orderId Yandex order number
     * @return bool true if the payment has actually been captured
     */
    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);
    }

    /**
     * Stores payment data in the HL block.
     * The unique index on UF_ORDER_ID blocks duplicate inserts.
     *
     * @param string $orderId Yandex order identifier
     * @param array  $data    Payment 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) {
            // Unique conflict — the record already exists, which is fine.
            // Other errors are logged: the payment exists in Yandex, but paymentUrl wasn't stored.
            self::log('Failed to save payment data: ' . $e->getMessage());
        }
    }

    /**
     * Reads the stored payment record from the HL block.
     *
     * @param string $orderId Yandex order identifier
     * @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;
    }

    /**
     * Updates the status of a record in the HL block.
     *
     * @param string $orderId Yandex order identifier
     * @param string $status  New 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]);
        }
    }

    /**
     * Returns the HL block data class with caching.
     * compileEntity() isn't free and gets called on every request.
     *
     * @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;
    }

    /**
     * Looks up a previously created Bitrix order by the Yandex order number.
     *
     * @param string $orderId Yandex order identifier
     * @return int ID of the found order, or 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;
    }

    /**
     * Logs a message to a file. Used for orders that failed to finalize
     * and other situations that need manual review.
     *
     * @param string $message 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
        );
    }

    /**
     * Creates an order in Bitrix based on data from the HL block.
     * A repeated call with the same orderId does not create a duplicate.
     *
     * @param array $arData Redirect data (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;
        }

        // The order already exists — a repeated hit to success URL does nothing
        if (self::findExistingOrder($orderId) > 0) {
            return;
        }

        // The payment was created by another user — don't create an order in the wrong account
        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 = 'Quick purchase via Yandex Split';
        $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 = 'Purchased from the product card via Yandex Split. Order number: ' . Clean::cleanString($orderId);
            if (!empty($record['UF_PAYMENT_URL'])) {
                $message .= PHP_EOL . PHP_EOL . 'Payment link: ' . $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) {
            // Money is captured, order wasn't created — mark and log.
            // Handled manually using the failed record in the HL block.
            self::updateStatus($orderId, self::STATUS_FAILED);
            self::log('Order creation failed for orderId=' . $orderId . ': ' . $e->getMessage());
        }
    }
}

Handling Orders That Failed to Finalize

If the payment is CAPTURED but the Bitrix order wasn't created, the record in the HL block stays in failed status and the failure details go to /local/logs/yapay.log. It's handled manually: look up the record by UF_ORDER_ID, check the payment in the Yandex dashboard, create the order through the admin panel if needed, and switch the status to completed. There's no automatic retry here — that should be a separate task with an idempotent queue.

The Return Handler

sale_ps_success.php receives the redirect from Yandex. It reads the GET parameters, calls YaSplit::createYaOrder(), and sends the user to the thank-you page. There must be no header.php here: it outputs HTML and sends headers, and a LocalRedirect() after that becomes a "headers already sent" error. Include only 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);

Fiscal Receipts (54-FZ)

Paying via Yandex Pay doesn't exempt you from fiscalization. The receipt must be sent at the moment of settlement, and settlement happens when payment->setPaid('Y') reaches the "paid" state. The standard Bitrix mechanism is binding a cash register to the payment system in the PaySystem settings. If an external fiscalization service is used, hook into the OnSalePaymentEntitySaved event and send the receipt from the handler, passing the payment amount, product name, and payment method attribute (full payment or credit — for Split, the latter).

Without fiscalization the system works, but the first receipt from the tax office will arrive sooner than you think.

Testing

Three scenarios worth checking before shipping to production.

Forging action=success

Take a created orderId from the HL block without paying. Open https://domain.ru/bitrix/tools/sale_ps_success.php?type=yapay&action=success&orderID=XXX manually. Expected behavior: checkOrderStatus() calls the Yandex API, gets PENDING, the order is created but setPaid('Y') is not called. If the order is marked paid — checkOrderStatus() is broken.

Repeated hit to the success URL

Right after the first successful return, open the same URL again. Expected behavior: findExistingOrder() finds the already-created order and nothing new is created. If a duplicate appears in the order list — the unique index on UF_ORDER_ID isn't set, or findExistingOrder() is broken.

Mocking the Yandex API

For local runs, mock getApiBase() — for instance, add a ya--api-base option and point tests at a local mock. The mock replies to POST /orders with a fake paymentUrl, and to GET /orders/{id} with CAPTURED or PENDING depending on the scenario. This exercises all branches of createYaOrder() without real captures.

Wrap-Up

The client loads the Yandex Pay SDK, mounts the button and the "Split" widget, and pulls the widget amount straight from the DOM — with a MutationObserver that correctly detaches when the container is removed. The server checks the Bitrix session, compares price and product against the catalog, creates a payment via the API, and stores the orderId → payment data pair in an HL block with a unique index. On return, the handler verifies the actual payment status via the Yandex API, checks the user, and creates the order exactly once — repeated hits to the success URL don't create duplicates. If the order couldn't be saved, the record stays in failed status and is logged: money is captured, the order is handled manually. The main failure points — unreachable API, missing 54-FZ register, wrong payment or delivery IDs — are configurable through options and don't take the page down.

Aleksandr Tichiy
Author

Aleksandr Tichiy

Fullstack developer · 1C-Bitrix

I build and modernize online stores and corporate portals on 1C-Bitrix. Integrations with 1C, high-load performance, 152-FZ compliance. Writing about architecture and e-commerce.


Comments

There are no comments yet, you can be the first to leave it

Leave a comment

The site uses a comment pre-moderation system, so your message will be published only after approval by the moderator

You are replying to a user's comment

Send

FEEDBACK

Email me

Please message me to discuss the task. I work remotely and officially (as a self-employed professional); my hourly rate is 1,800 RUB, and I can dedicate 3–4 hours a day to the project. I respond quickly, and the consultation is free.

An error occurred while sending, please try again after a while
Message sent successfully

Phones

+7(993) 007-18-96

Email

info@tichiy.ru

Address

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

By submitting the form, you automatically confirm that you have read and accept the Privacy Policy site