--- title: "Ошибки платежей" url: "https://docs.x-hub.online/payments/errors/" description: "Отмены, истечение срока, несовпадение суммы и коды отказа." relatedDocuments: - "https://docs.x-hub.online/payments/" - "https://docs.x-hub.online/payments/refunds/" - "https://docs.x-hub.online/webhooks/" --- # Обработка отмен, истечений и ошибок Не каждый платёж заканчивается успехом — часть клиентов закроет окно, часть не успеет за 15 минут, часть получит отказ банка, а небольшая доля уже оплаченных платежей может «вернуться» с возвратом. Этот раздел — про то, как корректно обработать каждую ветку у себя. ## Какие статусы означают неуспех { #kakie-statusy-oznachayut-neuspeh } | Status | Что значит | Деньги списаны? | Что делать | |---|---|---|---| | `cancelled` | Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) | Нет | Просто пометить как failed в своей системе. Дать клиенту создать новый платёж. | | `expired` | TTL истёк, клиент не оплатил | Пока нет | Дать клиенту повторить — нужен **новый** платёж. Этот **не** терминальный: поздняя оплата может поднять его в `paid` в течение 6 часов от создания. Закрывай заказ обратимо — см. [expired](./index.md#edge-case-expired). | | `failed` | Платёж не прошёл | Нет | Смотреть `failure_reason` — от него зависит действие: повторить через день, просить привязать заново или ничего не делать. См. [причина отказа](./index.md#failure-reason). | | `refunded` | Платёж был успешен, потом банк-эквайер вернул деньги клиенту | Да, но мы списали обратно с твоего баланса | **Критично** — пересчитать выдачу товара/услуги клиенту (отозвать доступ или выставить долг). | | `amount_mismatch` | Фактическая сумма не совпала с ожиданием | Деньги на удержании | Дождаться разбора админом X-Hub — прилетит следующий webhook с новым статусом (`paid` или `failed`). | !!! warning "`refunded` ≠ `cancelled`" `cancelled` приходит **до** успеха (деньги не двигались), `refunded` — **после** успеха (мы уже зачислили, потом отозвали). Если выдал товар на `paid` и пришёл `refunded` — у клиента остаётся товар, но денег ты уже не получишь. Это самый рискованный кейс. ## Code example — multi-status handler Полный обработчик, который покрывает все ветки: ### Node.js / TypeScript ```javascript 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 ```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 ```php-inline 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`. См. [Верификация подписи](../webhooks/index.md#verify-signature). - **Финальные статусы — их ровно три.** Терминальны только `failed`, `cancelled` и `refunded`. Остальное может измениться: - `expired` → `paid` (поздняя оплата, окно 6 часов от создания) или → `amount_mismatch` (поздняя оплата не на ту сумму). **`expired` терминальным не является** — см. [expired](./index.md#edge-case-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 — для нас это значит «доставлено», ретая не будет. ---