Перейти к содержанию

Обработка отмен, истечений и ошибок

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

Какие статусы означают неуспех

Status Что значит Деньги списаны? Что делать
cancelled Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) Нет Просто пометить как failed в своей системе. Дать клиенту создать новый платёж.
expired TTL истёк, клиент не оплатил Пока нет Дать клиенту повторить — нужен новый платёж. Этот не терминальный: поздняя оплата может поднять его в paid в течение 6 часов от создания. Закрывай заказ обратимо — см. expired.
failed Платёж не прошёл Нет Смотреть failure_reason — от него зависит действие: повторить через день, просить привязать заново или ничего не делать. См. причина отказа.
refunded Платёж был успешен, потом банк-эквайер вернул деньги клиенту Да, но мы списали обратно с твоего баланса Критично — пересчитать выдачу товара/услуги клиенту (отозвать доступ или выставить долг).
amount_mismatch Фактическая сумма не совпала с ожиданием Деньги на удержании Дождаться разбора админом X-Hub — прилетит следующий webhook с новым статусом (paid или failed).

refunded ≠ cancelled

cancelled приходит до успеха (деньги не двигались), refunded — после успеха (мы уже зачислили, потом отозвали). Если выдал товар на paid и пришёл refunded — у клиента остаётся товар, но денег ты уже не получишь. Это самый рискованный кейс.

Code example — multi-status handler

Полный обработчик, который покрывает все ветки:

Node.js / TypeScript

function handleXhubWebhook(event) {
  if (event.event !== 'payment.status_changed') return;

  switch (event.status) {
    case 'paid':
    case 'settled':
      // успешная оплата — выдать товар/услугу
      provisionService(event.payment_id, event.external_id);
      break;

    case 'cancelled':
    case 'expired':
      // клиент не оплатил — не выдавать, разрешить повтор.
      // ВАЖНО: пометка должна быть ОБРАТИМОЙ. `expired` не терминален —
      // поздняя оплата поднимет этот же payment_id в `paid` (окно 6 часов),
      // и тогда сюда прилетит второй webhook с успехом.
      markOrderAsAbandoned(event.external_id);
      break;

    case 'failed':
      // Платёж не прошёл. ЧТО делать — зависит от причины, а не от статуса:
      // на одинаковый `failed` бывает и «повтори через день, подписчик
      // останется», и «повторы бессмысленны».
      switch (event.failure_reason) {
        case 'insufficient_funds':
          // денег не хватило — подписку НЕ отменяем, пробуем позже
          scheduleRetry(event.subscription_id, { inDays: 2 });
          break;
        case 'mandate_revoked':
          // согласие отозвано — повторы не помогут, нужна новая привязка
          askCustomerToRebind(event.subscription_id);
          break;
        case 'expired':
          // «срок истёк» у платёжной системы — то же, что наш status:"expired"
          markOrderAsAbandoned(event.external_id);
          break;
        case 'cancelled_by_bank':
          // сессию погасил БАНК, срок оплаты ещё не истёк: плательщик ни при
          // чём, и «истёк срок» ему говорить нельзя. Совет тот же, что у
          // expired, но причина другая — не путать в аналитике.
          markOrderAsAbandoned(event.external_id);
          break;
        case 'cancelled_by_customer':
          // осознанный отказ — автоповторов не делаем
          markOrderAsAbandoned(event.external_id);
          break;
        default:
          // `declined_by_bank`, `unknown` и любой БУДУЩИЙ код: одна попытка,
          // потом человек. Отменять подписку по незнакомому коду нельзя —
          // почти всегда ситуация исправима.
          notifyClientOfFailure(event.external_id, event.payment_id);
          scheduleRetry(event.subscription_id, { inDays: 1, maxAttempts: 1 });
      }
      break;

    case 'refunded':
      // CRITICAL: возврат после успеха.
      // Если товар уже выдан — отозвать доступ или выставить долг клиенту.
      handleRefundAfterFulfillment(event.external_id, event.payment_id);
      alertOps('Refund-after-paid: ' + event.payment_id);
      break;

    case 'amount_mismatch':
      // ждать ручного разбора X-Hub — следующий webhook прилетит
      // с финальным статусом (paid или failed)
      pauseOrderUntilResolved(event.external_id);
      break;

    default:
      // неизвестный статус — лог + 200 (чтобы не словить retry-storm)
      logUnknownStatus(event);
  }
}

Python

def handle_xhub_webhook(event):
    if event['event'] != 'payment.status_changed':
        return

    status = event['status']
    ext_id = event['external_id']

    if status in ('paid', 'settled'):
        provision_service(event['payment_id'], ext_id)
    elif status in ('cancelled', 'expired'):
        mark_order_as_abandoned(ext_id)
    elif status == 'failed':
        notify_client_of_failure(ext_id, event['payment_id'])
    elif status == 'refunded':
        # CRITICAL: возврат после успеха
        handle_refund_after_fulfillment(ext_id, event['payment_id'])
        alert_ops(f"Refund-after-paid: {event['payment_id']}")
    elif status == 'amount_mismatch':
        pause_order_until_resolved(ext_id)
    else:
        log_unknown_status(event)

PHP

function handleXhubWebhook(array $event): void {
    if ($event['event'] !== 'payment.status_changed') return;

    $status = $event['status'];
    $extId = $event['external_id'];

    if ($status === 'paid' || $status === 'settled') {
        provisionService($event['payment_id'], $extId);
    } elseif ($status === 'cancelled' || $status === 'expired') {
        markOrderAsAbandoned($extId);
    } elseif ($status === 'failed') {
        notifyClientOfFailure($extId, $event['payment_id']);
    } elseif ($status === 'refunded') {
        // CRITICAL: возврат после успеха
        handleRefundAfterFulfillment($extId, $event['payment_id']);
        alertOps('Refund-after-paid: ' . $event['payment_id']);
    } elseif ($status === 'amount_mismatch') {
        pauseOrderUntilResolved($extId);
    } else {
        logUnknownStatus($event);
    }
}

Best practices

  • Идемпотентность через X-Webhook-Id. Тот же X-Webhook-Id — это retry от X-Hub (мы повторим, если твой endpoint не ответил 200 за 10 сек). Запиши X-Webhook-Id в свою БД и не выполняй action дважды для одного и того же id. Подробнее — раздел «Идемпотентность» в Webhooks ниже.
  • Idempotent action как таковой. Одно и то же событие (например paid для одного payment_id) может прийти несколько раз — provisionService() должна быть безопасна для повторного вызова (либо сама проверяет «уже выдали», либо UPSERT-операция).
  • Подпись обязательна. Не доверяй webhook'у безоговорочно — всегда проверяй X-Webhook-Signature. См. Верификация подписи.
  • Финальные статусы — их ровно три. Терминальны только failed, cancelled и refunded. Остальное может измениться:

    • expired → paid (поздняя оплата, окно 6 часов от создания) или → amount_mismatch (поздняя оплата не на ту сумму). expired терминальным не является — см. expired;
    • paid / settled → refunded (минуты-часы-дни-месяцы спустя);
    • amount_mismatch → paid или failed после разбора.

    Практическое правило: необратимые действия (списать товар со склада, окончательно закрыть заказ) вешай только на failed / cancelled. Всё остальное должно уметь «переиграться». - Узнавай о сбоях шлюза. Подпишись на gateway.status_changed (см. ниже) — если у X-Hub проблемы, мы сами пришлём webhook status: down/degraded с reason: api|completion|both. Используй это для своей системы алертов / мониторинга — не нужно опрашивать /health в цикле. - Не возвращай 200 ДО обработки. Если упал на DB-вставке — верни 5xx, X-Hub ретайнет. Если вернул 200 — для нас это значит «доставлено», ретая не будет.