Платежный шлюз — это критический финансовый контур любого интернет-магазина на WooCommerce. Ошибки в архитектуре шлюза или в логике синхронизации статусов оборачиваются для бизнеса прямыми материальными убытками (отгрузка неоплаченного товара, двойные списания средств у покупателей, блокировка торгового эккаунта банком за нарушение регламентов безопасности).
При переходе современной экосистемы WordPress на архитектуру высоких нагрузок разработчики сталкиваются со следующими серьезными вызовами:
wp_posts). Использование устаревших функций get_post_meta() или update_post_meta() в шлюзе приводит к фатальным сбоям, повреждению финансовой отчетности и невозможности включить HPOS в продакшене.processing на странице возврата пользователя (return_url). Покупатель может закрыть браузер сразу после ввода SMS-кода в банке, соединение может оборваться, либо злоумышленник может искусственно сымитировать GET-параметры ?order_id=123&status=success.Для создания надежного шлюза требуется строгое следование объектно-ориентированному контракту WC_Payment_Gateway, объявление совместимости с HPOS, использование сырого потока php://input для проверки подписи и реализация двухсторонних возвратов (process_refund).
Клиентский редирект (return_url) существует исключительно для визуального информирования покупателя: «Ваш платеж обрабатывается, спасибо за ожидание».
Никакая финансовая операция, списание остатков или перевод заказа в статус processing / completed не может происходить на странице редиректа! Единственным легитимным триггером завершения оплаты является асинхронный серверный вебхук от банка, прошедший трехфакторную верификацию:
hash_equals().Жизненный цикл платежа в WooCommerce представляет собой распределенную двухфазную транзакцию между тремя независимыми системами: браузером покупателя, сервером WooCommerce и процессинговым центром банка.
Плагин регистрирует поддержку кастомных таблиц заказов через FeaturesUtil::declare_compatibility. Класс шлюза наследует WC_Payment_Gateway и объявляет поддерживаемые функции ($supports = ['products', 'refunds']).
При отсутствии декларации HPOS магазин вынужден работать в режиме совместимости с деградацией производительности.
Метод шлюза извлекает объект WC_Order, переводит заказ в статус pending_payment, генерирует платежную сессию через защищенный HTTPS-запрос к API банка и возвращает массив с URL редиректа на страницу оплаты.
Корзина покупателя очищается, чтобы избежать повторного случайного оформления того же заказа.
Покупатель перенаправляется на процессинговый шлюз банка, вводит реквизиты карты и проходит двухфакторную аутентификацию 3DS в мобильном приложении банка.
WooCommerce в этот момент находится в режиме ожидания асинхронного события.
Банк отправляет POST-запрос на специальный API-маршрут сайта. Сервер считывает сырой поток php://input, вычисляет HMAC-SHA256 подпись по секретному ключу и сравнивает ее с заголовком X-Signature.
Сверка сумм и валюты защищает от атак подмены платежных параметров.
Если заказ еще не оплачен, вызывается $order->payment_complete($txn_id). При повторных вебхуках возвращается 200 OK без дублирования действий. Для возвратов из админки реализуется метод process_refund().
Все события маскируются и протоколируются в журнал WooCommerce по стандарту PCI DSS.
Покупатель нажимает «Оформить заказ». WooCommerce создает заказ со статусом pending_payment
Вызов process_payment(): расчет копеек, HTTPS-запрос к банку, получение redirect_url
Покупатель авторизует списание на защищенном хосте банка; банк списывает средства
Банк отправляет фоновый POST на /?wc-api=agenticlab_pay с сырым телом и HMAC-заголовком
Проверка HMAC подписи, проверка валюты и суммы, проверка текущего статуса (Idempotency)
Вызов $order->payment_complete(), отправка писем покупателю, переход в processing
Почему при разработке современного платежного шлюза для WooCommerce категорически запрещено использовать нативные функции WordPress update_post_meta($order_id, ...) и get_post_meta($order_id, ...)?
Вследствие задержки сети банк прислал повторный вебхук об успешной оплате заказа №5521 спустя 20 секунд после первого. Как профессиональный шлюз обязан обработать данный запрос?
Ниже представлены четыре готовых компонента, образующих полноценный платежный шлюз уровня Enterprise.
Файл плагина обеспечивает строгую изоляцию, проверку наличия активного WooCommerce и раннее объявление совместимости с таблицами заказов HPOS через FeaturesUtil.
<?php
/**
* Plugin Name: Fullstack Agentic Lab Enterprise Payment Gateway for WooCommerce
* Plugin URI: https://agenticlab.dev/workflows/wordpress/wf-wc-payment-gateway
* Description: Промышленный платежный шлюз с поддержкой HPOS, HMAC-SHA256 вебхуками, идемпотентностью и API возвратов.
* Version: 2.0.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
* Text Domain: agenticlab-pay
* Requires at least: 6.4
* Requires PHP: 8.2
* WC requires at least: 8.0
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Объявление совместимости с High-Performance Order Storage (HPOS).
* Хук before_woocommerce_init срабатывает ДО инициализации структур WooCommerce.
*/
add_action( 'before_woocommerce_init', static function (): void {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'cart_checkout_blocks',
__FILE__,
true
);
}
} );
/**
* Инициализация шлюза после полной загрузки плагинов.
*/
add_action( 'plugins_loaded', static function (): void {
if ( ! class_exists( 'WC_Payment_Gateway' ) ) {
return;
}
require_once __DIR__ . '/includes/class-agenticlab-gateway-logger.php';
require_once __DIR__ . '/includes/class-agenticlab-webhook-handler.php';
require_once __DIR__ . '/includes/class-wc-gateway-agenticlab.php';
// Регистрация класса в реестре платежных систем WooCommerce
add_filter( 'woocommerce_payment_gateways', static function ( array $gateways ): array {
$gateways[] = \AgenticLab\Payments\WC_Gateway_AgenticLab::class;
return $gateways;
} );
} );
Класс реализует ядро платежной логики: форму настроек с безопасными полями, метод process_payment с переводом в pending_payment и метод process_refund для возвратов из панели управления.
<?php
declare(strict_types=1);
namespace AgenticLab\Payments;
use WC_Payment_Gateway;
use WC_Order;
use WP_Error;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Промышленный платежный шлюз Fullstack Agentic Lab Pay.
* Наследует WC_Payment_Gateway и реализует контракт HPOS.
*/
class WC_Gateway_AgenticLab extends WC_Payment_Gateway {
private string $api_key;
private string $secret_key;
private string $webhook_secret;
private bool $testmode;
public function __construct() {
$this->id = 'agenticlab_pay';
$this->icon = apply_filters( 'agenticlab_pay_icon', '' );
$this->has_fields = false;
$this->method_title = __( 'Fullstack Agentic Lab Pay Gateway', 'agenticlab-pay' );
$this->method_description = __( 'Прием защищенных платежей картами через шлюз Fullstack Agentic Lab с мгновенной верификацией.', 'agenticlab-pay' );
// Декларация возможностей: поддержка стандартных товаров и онлайн-возвратов
$this->supports = [
'products',
'refunds',
];
// Загрузка формы настроек и параметров
$this->init_form_fields();
$this->init_settings();
$this->title = (string) $this->get_option( 'title', 'Банковская карта (Fullstack Agentic Lab Pay)' );
$this->description = (string) $this->get_option( 'description', 'Безопасная оплата через 3D-Secure 2.0.' );
$this->enabled = (string) $this->get_option( 'enabled', 'no' );
$this->testmode = 'yes' === $this->get_option( 'testmode', 'yes' );
$this->api_key = (string) $this->get_option( 'api_key' );
$this->secret_key = (string) $this->get_option( 'secret_key' );
$this->webhook_secret = (string) $this->get_option( 'webhook_secret' );
// Сохранение настроек в панели администратора
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, [ $this, 'process_admin_options' ] );
// Инициализация сервиса обработки асинхронных вебхуков банка
$webhook_handler = new AgenticLab_Webhook_Handler( $this->id, $this->webhook_secret );
$webhook_handler->init();
}
/**
* Поля конфигурации шлюза в админке WooCommerce.
*/
public function init_form_fields(): void {
$this->form_fields = [
'enabled' => [
'title' => __( 'Включить / Выключить', 'agenticlab-pay' ),
'type' => 'checkbox',
'label' => __( 'Включить прием платежей через Fullstack Agentic Lab Pay', 'agenticlab-pay' ),
'default' => 'no',
],
'title' => [
'title' => __( 'Название метода', 'agenticlab-pay' ),
'type' => 'text',
'description' => __( 'Название, которое покупатель видит на этапе чекаута.', 'agenticlab-pay' ),
'default' => __( 'Банковская карта онлайн', 'agenticlab-pay' ),
'desc_tip' => true,
],
'description' => [
'title' => __( 'Описание метода', 'agenticlab-pay' ),
'type' => 'textarea',
'description' => __( 'Поясняющий текст под названием метода.', 'agenticlab-pay' ),
'default' => __( 'Оплата картами Visa, Mastercard, МИР. Безопасное соединение.', 'agenticlab-pay' ),
],
'testmode' => [
'title' => __( 'Тестовый режим (Sandbox)', 'agenticlab-pay' ),
'type' => 'checkbox',
'label' => __( 'Включить режим песочницы для тестирования', 'agenticlab-pay' ),
'default' => 'yes',
'description' => __( 'В тестовом режиме реальные списания с карт не производятся.', 'agenticlab-pay' ),
],
'api_key' => [
'title' => __( 'Public API Key (Merchant ID)', 'agenticlab-pay' ),
'type' => 'text',
'description' => __( 'Идентификатор мерчанта, выданный процессингом банка.', 'agenticlab-pay' ),
],
'secret_key' => [
'title' => __( 'Secret API Key', 'agenticlab-pay' ),
'type' => 'password',
'description' => __( 'Секретный ключ для подписи исходящих запросов к API банка.', 'agenticlab-pay' ),
],
'webhook_secret' => [
'title' => __( 'Webhook Secret Key', 'agenticlab-pay' ),
'type' => 'password',
'description' => __( 'Секретная строка для проверки подписи HMAC входящих вебхуков.', 'agenticlab-pay' ),
],
];
}
/**
* Обработка нажатия кнопки «Подтвердить заказ» на чекауте.
*/
public function process_payment( $order_id ): array {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
wc_add_notice( __( 'Заказ не найден.', 'agenticlab-pay' ), 'error' );
return [ 'result' => 'failure' ];
}
// Переводим заказ в статус ожидания оплаты
$order->update_status( 'pending', __( 'Инициирована оплата через Fullstack Agentic Lab Pay.', 'agenticlab-pay' ) );
// Расчет суммы в минимальных неделимых единицах (центы / копейки)
$amount_in_cents = (int) round( (float) $order->get_total() * 100 );
$currency = $order->get_currency();
// Формирование защищенной сессии на сервере банка
$payment_session = $this->create_bank_checkout_session( $order, $amount_in_cents, $currency );
if ( is_wp_error( $payment_session ) ) {
AgenticLab_Gateway_Logger::error( sprintf( 'Сбой создания сессии заказа #%d: %s', $order_id, $payment_session->get_error_message() ) );
wc_add_notice( __( 'Не удалось связаться с банком. Пожалуйста, попробуйте позже.', 'agenticlab-pay' ), 'error' );
return [ 'result' => 'failure' ];
}
// Сохранение ID платежной сессии через HPOS CRUD-метод
$order->update_meta_data( '_agenticlab_session_id', $payment_session['session_id'] );
$order->save();
// Очищаем корзину покупателя
WC()->cart->empty_cart();
// Возвращаем браузерный редирект на защищенный хост банка
return [
'result' => 'success',
'redirect' => $payment_session['payment_url'],
];
}
/**
* Вызов API банка для создания платежной сессии.
*/
private function create_bank_checkout_session( WC_Order $order, int $amount, string $currency ): array|WP_Error {
$api_endpoint = $this->testmode
? 'https://sandbox.api.agenticlab.dev/v1/payments'
: 'https://api.agenticlab.dev/v1/payments';
$payload = [
'merchant_id' => $this->api_key,
'order_id' => (string) $order->get_id(),
'amount' => $amount,
'currency' => $currency,
'return_url' => $this->get_return_url( $order ),
'cancel_url' => $order->get_cancel_order_url(),
'customer' => [
'email' => $order->get_billing_email(),
'phone' => $order->get_billing_phone(),
],
];
// Подписание запроса секретным ключом
$body = (string) wp_json_encode( $payload );
$signature = hash_hmac( 'sha256', $body, $this->secret_key );
$response = wp_remote_post( $api_endpoint, [
'timeout' => 30, // Строгий таймаут для защиты пула воркеров PHP-FPM
'headers' => [
'Content-Type' => 'application/json',
'X-Signature' => $signature,
],
'body' => $body,
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code && 201 !== $code ) {
return new WP_Error( 'bank_api_error', $data['message'] ?? 'Неизвестная ошибка банка', [ 'status' => $code ] );
}
return [
'session_id' => (string) ( $data['id'] ?? '' ),
'payment_url' => (string) ( $data['payment_url'] ?? '' ),
];
}
/**
* Автоматический возврат средств покупателю из админки WooCommerce.
*/
public function process_refund( $order_id, $amount = null, $reason = '' ): bool|WP_Error {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return new WP_Error( 'invalid_order', __( 'Заказ не найден.', 'agenticlab-pay' ) );
}
$txn_id = $order->get_transaction_id();
if ( empty( $txn_id ) ) {
return new WP_Error( 'missing_txn', __( 'У заказа отсутствует ID транзакции банка для возврата.', 'agenticlab-pay' ) );
}
$refund_amount_cents = (int) round( ( (float) $amount ) * 100 );
$api_endpoint = $this->testmode
? 'https://sandbox.api.agenticlab.dev/v1/refunds'
: 'https://api.agenticlab.dev/v1/refunds';
$payload = [
'transaction_id' => $txn_id,
'amount' => $refund_amount_cents,
'reason' => sanitize_text_field( $reason ?: 'Возврат через панель управления' ),
];
$body = (string) wp_json_encode( $payload );
$signature = hash_hmac( 'sha256', $body, $this->secret_key );
$response = wp_remote_post( $api_endpoint, [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'X-Signature' => $signature,
],
'body' => $body,
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code ) {
return new WP_Error( 'refund_failed', $data['message'] ?? 'Банк отклонил возврат', [ 'status' => $code ] );
}
$order->add_order_note(
sprintf(
__( 'Успешный возврат через Fullstack Agentic Lab Pay на сумму %s. ID возврата банка: %s', 'agenticlab-pay' ),
wc_price( (float) $amount ),
esc_html( (string) ( $data['refund_id'] ?? '' ) )
)
);
return true;
}
}
Сервис слушает эндпоинт /?wc-api=agenticlab_pay, выполняет криптографическую проверку подписи по сырому потоку php://input, защищает от подмены суммы и гарантирует идемпотентность при повторах.
<?php
declare(strict_types=1);
namespace AgenticLab\Payments;
use WC_Order;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Обработчик асинхронных уведомлений (Webhooks / IPN) от процессинга банка.
* Реализует валидацию HMAC-SHA256, сверку сумм/валют и идемпотентность.
*/
class AgenticLab_Webhook_Handler {
private string $gateway_id;
private string $webhook_secret;
public function __construct( string $gateway_id, string $webhook_secret ) {
$this->gateway_id = $gateway_id;
$this->webhook_secret = $webhook_secret;
}
/**
* Регистрация системного маршрута WooCommerce API.
* URL: https://site.com/?wc-api=agenticlab_pay
*/
public function init(): void {
add_action( 'woocommerce_api_' . $this->gateway_id, [ $this, 'handle_incoming_webhook' ] );
}
/**
* Основной контроллер обработки вебхука.
*/
public function handle_incoming_webhook(): void {
// Чтение СЫРОГО потока входных данных! $_POST повреждает JSON-структуру и ломает подпись
$raw_body = file_get_contents( 'php://input' );
$headers = $this->get_request_headers();
$signature = $headers['x-signature'] ?? $headers['X-Signature'] ?? '';
if ( empty( $raw_body ) || empty( $signature ) ) {
AgenticLab_Gateway_Logger::warning( 'Webhook отклонен: пустое тело или отсутствует заголовок подписи X-Signature.' );
$this->send_response( 400, 'Bad Request: Missing payload or signature' );
}
// 1. Криптографическая верификация подписи HMAC-SHA256
$expected_signature = hash_hmac( 'sha256', $raw_body, $this->webhook_secret );
if ( ! hash_equals( $expected_signature, (string) $signature ) ) {
AgenticLab_Gateway_Logger::error( 'Webhook отклонен: недействительная подпись HMAC-SHA256!' );
$this->send_response( 403, 'Forbidden: Invalid signature' );
}
$data = json_decode( $raw_body, true );
if ( ! is_array( $data ) ) {
$this->send_response( 400, 'Invalid JSON body' );
}
$order_id = (int) ( $data['order_id'] ?? 0 );
$status = (string) ( $data['status'] ?? '' );
$transaction_id = (string) ( $data['transaction_id'] ?? '' );
$paid_cents = (int) ( $data['amount'] ?? 0 );
$currency = (string) ( $data['currency'] ?? '' );
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
AgenticLab_Gateway_Logger::warning( sprintf( 'Webhook: Заказ #%d не найден в базе данных.', $order_id ) );
$this->send_response( 404, 'Order Not Found' );
}
// 2. Проверка идемпотентности: если заказ уже оплачен, подтверждаем банку прием без действий
if ( $order->has_status( [ 'processing', 'completed' ] ) ) {
AgenticLab_Gateway_Logger::info( sprintf( 'Идемпотентный повтор: Заказ #%d уже оплачен ранее. Возврат 200 OK.', $order_id ) );
$this->send_response( 200, 'OK (Already Processed)' );
}
// 3. Защита от подмены суммы и валюты (Amount & Currency Mismatch Attack)
$expected_cents = (int) round( (float) $order->get_total() * 100 );
if ( $paid_cents !== $expected_cents ) {
AgenticLab_Gateway_Logger::error( sprintf(
'Критическая ошибка безопасности! Несовпадение сумм заказа #%d: ожидалось %d центов, получено %d центов.',
$order_id,
$expected_cents,
$paid_cents
) );
$order->update_status( 'failed', __( 'Оплата отклонена: сумма в вебхуке не соответствует сумме заказа!', 'agenticlab-pay' ) );
$this->send_response( 400, 'Amount mismatch' );
}
if ( strtoupper( $currency ) !== strtoupper( $order->get_currency() ) ) {
AgenticLab_Gateway_Logger::error( sprintf(
'Несовпадение валюты заказа #%d: ожидалось %s, получено %s.',
$order_id,
$order->get_currency(),
$currency
) );
$order->update_status( 'failed', __( 'Оплата отклонена: несоответствие валюты.', 'agenticlab-pay' ) );
$this->send_response( 400, 'Currency mismatch' );
}
// 4. Фиксация успешного платежа
if ( 'SUCCESS' === strtoupper( $status ) ) {
$order->update_meta_data( '_agenticlab_transaction_id', $transaction_id );
$order->save();
// payment_complete переводит в processing/completed, очищает корзину и запускает триггеры писем
$order->payment_complete( $transaction_id );
$order->add_order_note( sprintf(
__( 'Оплата успешно подтверждена вебхуком банка. ID транзакции: %s', 'agenticlab-pay' ),
esc_html( $transaction_id )
) );
AgenticLab_Gateway_Logger::info( sprintf( 'Заказ #%d успешно переведен в статус оплачен (TXN: %s).', $order_id, $transaction_id ) );
$this->send_response( 200, 'OK' );
}
// Обработка отклонения платежа банком
$order->update_status( 'failed', sprintf( __( 'Платеж отклонен банком: %s', 'agenticlab-pay' ), esc_html( $data['error_message'] ?? 'Unknown' ) ) );
$this->send_response( 200, 'OK (Marked Failed)' );
}
private function get_request_headers(): array {
if ( function_exists( 'getallheaders' ) ) {
return getallheaders() ?: [];
}
$headers = [];
foreach ( $_SERVER as $name => $value ) {
if ( str_starts_with( $name, 'HTTP_' ) ) {
$headers[ str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ) ] = $value;
}
}
return $headers;
}
private function send_response( int $code, string $message ): never {
status_header( $code );
header( 'Content-Type: application/json; charset=utf-8' );
echo wp_json_encode( [ 'status' => $code, 'message' => $message ] );
exit;
}
}
Логирует системные события и сетевые взаимодействия через нативный wc_get_logger() с гарантированным вырезанием номеров карт и секретных токенов.
<?php
declare(strict_types=1);
namespace AgenticLab\Payments;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Логгер платежного шлюза, соответствующий требованиям PCI DSS.
* Автоматически маскирует номера карт и предотвращает утечку секретов в логи.
*/
class AgenticLab_Gateway_Logger {
private const CONTEXT = [ 'source' => 'agenticlab-pay' ];
public static function info( string $message ): void {
self::log( 'info', $message );
}
public static function warning( string $message ): void {
self::log( 'warning', $message );
}
public static function error( string $message ): void {
self::log( 'error', $message );
}
private static function log( string $level, string $message ): void {
if ( ! function_exists( 'wc_get_logger' ) ) {
return;
}
$sanitized_message = self::mask_sensitive_data( $message );
wc_get_logger()->log( $level, $sanitized_message, self::CONTEXT );
}
/**
* Маскирование чувствительных данных (номера карт PAN, секретные токены).
*/
private static function mask_sensitive_data( string $content ): string {
// Маскирование номеров банковских карт (сохраняем первые 6 и последние 4 цифры)
$pan_regex = '/\b(\d{6})\d{2,8}(\d{4})\b/';
$content = preg_replace( $pan_regex, '$1******$2', $content );
// Вырезание CVV кодов при их случайном появлении
$cvv_regex = '/"cvv"\s*:\s*"?[0-9]{3,4}"?/i';
$content = preg_replace( $cvv_regex, '"cvv":"***"', $content );
// Маскирование секретных ключей
$secret_regex = '/(secret[_-]?key|webhook[_-]?secret)["']?\s*[:=]\s*["']?([a-zA-Z0-9_-]{8,})["']?/i';
return (string) preg_replace( $secret_regex, '$1="********"', (string) $content );
}
}
При разработке кастомных платежных шлюзов наиболее часто встречаются следующие критические ошибки:
$_POST вместо потока php://input#403 Forbidden, хотя секретный ключ в настройках указан верно.$_POST, преобразуя типы данных, сортируя ключи и декодируя URL-символы. Подпись банка рассчитывается на основе оригинальной байтовой строки JSON.file_get_contents('php://input') до любых манипуляций с данными.return_url#pending или отменился по тайм-ауту.return_url должна только показывать статус ожидания. Перевод заказа в processing выполняется исключительно по асинхронному вебхуку банка.$amount * 100) в силу особенностей IEEE 754 дает 1998.9999999999998, что при приведении к (int) превращается в 1998 копеек.(int) round( (float) $order->get_total() * 100 ).dbDelta и автозагрузка.