Доступен — Локальные цифровые продукты
HCA · Studio
TR — Контакты ↗
Заметка 13 · Эквайринг

Деньги списаны, заказ всё ещё ждёт оплаты

Покупатель видит списание по карте, а в админке заказ по-прежнему «В ожидании оплаты». Через час WooCommerce его отменяет. Проблема не в банке, а в том, каким путём результат возвращается в магазин.

Тема
Возврат после 3D Secure
Язык
PHP
Симптом
Оплачено, но ждёт
Короткий ответ

В моделях 3D Host и 3D Pay, которые используют большинство банковских виртуальных POS, результат платежа приходит в магазин через браузер покупателя — POST-запросом на адрес успеха или ошибки. Если покупатель после оплаты закрыл вкладку, потерял связь или перенаправление зависло, этот запрос не приходит никогда: деньги списаны, заказ остаётся в ожидании. Решение — не полагаться на браузер и запрашивать статус у сервиса банка со своего сервера (сверка): через несколько минут после попытки оплаты и когда покупатель попадает на страницу «Спасибо за заказ». Заказ не завершается без проверки суммы и отмены.

Симптом

Жалоба обычно приходит по телефону: «С карты списали деньги, а заказ не подтверждён». В админке заказ «В ожидании оплаты» — или уже «Отменён». В журнале ошибок пусто: с точки зрения сервера ничего не сломалось, до него просто ничего не дошло.

Откуда приходит результат

В распространённых моделях банковского эквайринга (шлюзы на базе Nestpay, PayFor банка QNB и подобные) поток 3D Host/3D Pay выглядит так:

  1. Магазин отправляет подписанную форму через браузер покупателя на платёжную страницу банка
  2. Покупатель вводит данные карты и код 3D Secure на стороне банка; банк списывает деньги
  3. Банк сообщает результат, заставляя браузер покупателя сделать POST на адрес успеха или ошибки магазина
  4. Магазин проверяет хеш и завершает заказ

Хрупкий шаг — третий. Этот запрос уходит с устройства покупателя. Если пропала мобильная связь, покупатель увидел «оплата прошла» и закрыл вкладку, страница банка тормозила и он нажал «назад» или встроенный браузер приложения «проглотил» перенаправление — магазин так и не узнает результат. Платёж состоялся, известие о нём потерялось по дороге.

Некоторые платёжные сервисы предлагают адрес уведомлений «сервер — сервер» и сообщают результат независимо от покупателя. У большинства классических банковских платформ надёжного уведомления нет; единственный надёжный способ узнать результат — спросить.

Ловушка удержания запасов

Ситуацию усугубляет одна настройка WooCommerce: Настройки → Товары → Запасы → Удерживать запасы (минут). При включённом управлении запасами заказы, созданные при оформлении и остающиеся «В ожидании оплаты» дольше этого времени, отменяются автоматически. То есть платёж с потерянным возвратом через час превращается в «оплаченный, но отменённый заказ».

Запускайте сверку до истечения этого времени. Кроме того, сверка должна уметь проверять недавно отменённые заказы вашего шлюза: payment_complete() принимает отменённый заказ как допустимый статус для завершения.

Сверка

Идея проста: вместо ожидания результата от браузера спросить сервис запроса статуса заказа у банка со своего сервера. Достаточно двух триггеров:

  • Плановая проверка: разовая задача через несколько минут после каждой попытки оплаты
  • Страница «Спасибо за заказ»: если при возвращении покупателя заказ всё ещё выглядит неоплаченным — немедленный запрос
// Когда начинается попытка оплаты
$ids   = (array) $order->get_meta( '_pos_order_ids' );
$ids[] = $bank_order_id;                       // номер заказа в банке для этой попытки
$order->update_meta_data( '_pos_order_ids', $ids );
$order->save();
wp_schedule_single_event( time() + 600, 'mypos_reconcile', array( $order->get_id() ) );

add_action( 'mypos_reconcile', 'mypos_reconcile' );
add_action( 'woocommerce_thankyou_my_gateway', 'mypos_reconcile' );

function mypos_reconcile( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( ! $order || $order->is_paid() ) {
        return;
    }
    foreach ( array_reverse( (array) $order->get_meta( '_pos_order_ids' ) ) as $bank_id ) {
        $r = mypos_inquiry( $bank_id );          // запрос в банк «сервер — сервер»
        if ( ! $r || '00' !== $r['code'] || $r['voided'] ) {
            continue;
        }
        if ( $r['amount'] !== wc_format_decimal( $order->get_total(), 2 ) ) {
            $order->add_order_note( 'Банк сообщил другую сумму — проверьте вручную.' );
            return;
        }
        $order->payment_complete( $r['ref'] );
        $order->add_order_note( 'Сверка: банк подтвердил оплату.' );
        return;
    }
}

WP-Cron запускается визитами; на сайтах с небольшим трафиком задача может выполниться с опозданием. Для надёжности вызывайте wp cron event run --due-now из системного cron.

Запрос в банк

В отличие от платёжной формы, запрос выполняется «сервер — сервер» и авторизуется пользователем с API-ролью. Например, на платформе PayFor банка QNB на адрес XmlGate.aspx отправляется такой запрос:

<?xml version="1.0" encoding="UTF-8"?>
<PayforRequest>
  <MbrId>5</MbrId>
  <MerchantId>…</MerchantId>
  <UserCode>…</UserCode>
  <UserPass>…</UserPass>
  <OrgOrderId>ORD1234A2</OrgOrderId>
  <SecureType>Inquiry</SecureType>
  <TxnType>OrderInquiry</TxnType>
  <Lang>EN</Lang>
</PayforRequest>

Если в ответе ProcReturnCode равен 00, TxnType — Auth, а VoidDate пуст или равен нулю, платёж является действительной продажей; PurchAmount содержит сумму. На шлюзах на базе Nestpay то же самое делается добавлением ORDERSTATUS=QUERY в поле Extra запроса. Названия у банков разные, идея одна.

Два практических замечания: банки обычно ограничивают доступ к этому сервису по IP — попросите банк добавить IP вашего сервера. И при неверных учётных данных сервис всё равно отвечает HTTP 200 с кодом ошибки: интеграция, не читающая тело ответа, примет это за «не оплачено».

Несколько попыток

Банки не принимают вторую транзакцию с тем же номером заказа, поэтому каждая попытка оплаты получает новый номер (ORD1234A1, ORD1234A2…). Здесь скрыт тихий риск двойного списания:

Покупатель оплатил с первой попытки, но возврат потерялся; заказ выглядит неоплаченным, покупатель пробует снова, и вторая попытка не проходит. Сверка, проверяющая только последний номер, скажет «не оплачено», и покупатель заплатит ещё раз. Поэтому храните все номера попыток заказа и проверяйте их все, начиная с последнего.

Перед завершением

  • Сумма: сумма от банка должна совпадать с итогом заказа; если нет — не завершайте автоматически, оставьте заметку для ручной проверки
  • Отмена: транзакция, отменённая в тот же день, может вернуться с кодом «успех»; проверяйте поле отмены отдельно
  • Повторы: если заказ уже оплачен, ничего не делайте — возврат и сверка могут сработать одновременно
  • Используйте payment_complete(): ручная смена статуса пропускает списание запасов и письма

Мы добавили этот слой в интеграцию виртуального POS банка QNB для Castor Coffee: номера всех попыток сохраняются, банк опрашивается через десять минут и на странице «Спасибо за заказ», а при расхождении суммы заказ автоматически не завершается.

Итог

При 3D Secure результат возвращается через самое ненадёжное звено — браузер покупателя. Когда оно рвётся, деньги списаны, заказ висит, а по истечении времени удержания запасов отменяется. Надёжное решение: спрашивать банк со своего сервера. Храните номера всех попыток, запрашивайте статус до истечения времени удержания и не завершайте заказ без проверки суммы и отмены. Адрес возврата — удобство для хорошего дня; сверка — страховка на плохой.

Быстрая справка
Симптом
Списание есть, заказ «В ожидании оплаты» или отменён
Причина
Результат 3D возвращается через браузер покупателя; закрыл вкладку — не пришёл
Решение
Запрос статуса в банк «сервер — сервер» (сверка)
Когда
~10 минут после попытки + на странице «Спасибо за заказ»
PayFor (QNB)
XmlGate.aspx · SecureType=Inquiry · TxnType=OrderInquiry
Nestpay
Extra → ORDERSTATUS=QUERY в запросе
Ловушка
По истечении удержания запасов ожидающие заказы отменяются автоматически
Правило
Проверяйте все номера попыток; не завершайте без проверки суммы и отмены
Частые вопросы

О сверке платежей.

С карты покупателя списали деньги, а заказ WooCommerce в ожидании оплаты. Почему?

При 3D Secure банк сообщает результат магазину через браузер покупателя — POST-запросом на адрес успеха. Если покупатель после оплаты закрыл вкладку или потерял связь, этот запрос не приходит. Платёж состоялся, но магазин о нём не узнал.

Почему такие заказы через некоторое время отменяются?

Из-за настройки удержания запасов WooCommerce. При включённом управлении запасами заказы, ожидающие оплаты дольше заданного числа минут, отменяются автоматически. Запускайте сверку раньше и проверяйте также недавно отменённые заказы своего шлюза.

Что нужно для сверки?

Доступ к сервису запроса статуса заказа в банке: пользователь с API-ролью и IP-адрес вашего сервера, добавленный банком в разрешённые. Запрос идёт «сервер — сервер» и не зависит от браузера покупателя.

Зачем проверять все попытки оплаты?

Каждая попытка использует новый номер заказа в банке. Если первая попытка прошла, но её возврат потерялся, а вторая не прошла, проверка только последнего номера скажет «не оплачено», и покупатель заплатит снова. Проверка всех номеров, начиная с последнего, исключает двойное списание.

Доступность и смета

Есть идея?
Можно даже одной фразой.