Содержание

Идемпотентность платежа означает, что повторный запрос на ту же оплату не приводит ко второму списанию, а повторное уведомление банка не меняет статус заказа дважды. Её обеспечивают три вещи: ключ идемпотентности в каждом запросе к платёжному провайдеру, уникальные ограничения в базе и обработка вебхуков, которая спокойно переживает дубли и перестановку событий.

Разберём, откуда берутся двойные списания и как построить платёжный контур, в котором их нет.

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

Двойные списания возникают, потому что в сети любой запрос может быть отправлен повторно: пользователь нажал кнопку дважды, клиент не дождался ответа и повторил запрос, фоновая задача упала после успешной оплаты и перезапустилась. Если система каждый такой запрос считает новым, деньги списываются снова.

Типичные источники повторов:

  • двойной клик на кнопке «Оплатить» или обновление страницы;
  • мобильное приложение повторяет запрос при потере связи;
  • фоновая задача перезапускается после сбоя уже после вызова платёжного API;
  • платёжный провайдер повторно присылает вебхук, если не получил быстрый ответ 2xx;
  • вебхуки приходят не в том порядке, в котором произошли события.

Все эти случаи штатные — их нельзя «исправить», их нужно учитывать в архитектуре.

Что такое ключ идемпотентности?

Ключ идемпотентности — это уникальный идентификатор операции, который клиент передаёт вместе с запросом. Сервер запоминает ключ и результат: если запрос с тем же ключом приходит снова, он возвращает сохранённый результат вместо повторного выполнения. Многие платёжные провайдеры поддерживают такой ключ в заголовке запроса.

Как это устроить у себя:

  1. Ключ создаётся один раз на операцию — например, при создании попытки оплаты заказа — и сохраняется.
  2. Все повторы этой операции, включая ретраи фоновых задач, используют тот же ключ.
  3. На стороне сервера таблица ключей с уникальным индексом хранит статус и результат.
  4. Если запрос с ключом ещё выполняется, повтор получает ответ «в обработке», а не запускает второй.
Где нужен ключ Что защищает Кто создаёт ключ
Запрос на создание платежа к провайдеру От второго списания при ретрае Ваш бэкенд, один на попытку оплаты
Ваш API оплаты для фронтенда и приложения От двойного клика и повторов клиента Фронтенд или мобильное приложение
Обработка вебхука От повторной обработки одного события Провайдер — идентификатор события
Фоновые задачи вокруг оплаты От дублей при перезапуске задачи Идентификатор заказа или платежа

Банковская карта на клавиатуре ноутбука Фото: Unsplash

Как правильно обрабатывать вебхуки платёжной системы?

Вебхук обрабатывают так: проверяют подпись, сохраняют событие с уникальным идентификатором, быстро отвечают кодом 200 и уже затем выполняют бизнес-логику в фоне. Повторное событие с тем же идентификатором просто пропускается.

public function handle(Request $request, PaymentProvider $provider): Response
{
    // 1. Подпись: событие действительно от провайдера
    $event = $provider->verifyWebhook($request->getContent(), $request->header('Signature'));

    // 2. Уникальный идентификатор события — повтор не создаст вторую запись
    $stored = PaymentEvent::query()->insertOrIgnore([
        'provider_event_id' => $event->id,
        'type'              => $event->type,
        'payload'           => $request->getContent(),
        'created_at'        => now(),
    ]);

    // 3. Новое событие — в очередь, провайдеру отвечаем сразу
    if ($stored) {
        ProcessPaymentEvent::dispatch($event->id)->afterCommit();
    }

    return response()->noContent();
}

Порядок событий и переходы статусов

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

Какие ограничения в базе данных защищают от дублей?

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

Что стоит закрепить уникальными индексами:

  • идентификатор события провайдера в таблице вебхуков;
  • ключ идемпотентности в таблице операций;
  • идентификатор платежа у провайдера в таблице платежей;
  • одна успешная оплата на заказ — частичным уникальным индексом по статусу.
CREATE UNIQUE INDEX payments_one_success_per_order
    ON payments (order_id)
    WHERE status = 'succeeded';

Как проверить, что платёжный контур надёжен?

Надёжность платёжного контура проверяют тестами на повторы и регулярной сверкой с провайдером. Тесты воспроизводят двойной клик, повторный вебхук и перестановку событий, а сверка раз в сутки находит расхождения, которые пропустила логика.

Чек-лист:

  1. Тест: два одновременных запроса на оплату одного заказа — одно списание.
  2. Тест: один вебхук, отправленный трижды, — один переход статуса и одно письмо.
  3. Тест: вебхук возврата раньше вебхука оплаты — итоговый статус корректен.
  4. Ежедневная сверка платежей в системе с отчётом провайдера.
  5. Алерт на расхождения и на вебхуки, которые не удалось обработать.

Общие правила надёжных фоновых задач — повторы, таймауты, идемпотентность — мы разбирали в статье очереди задач в веб-приложении, а проектирование API — в статье как спроектировать REST API.

Коротко

  • Повторы запросов и вебхуков — штатная ситуация, архитектура должна их выдерживать.
  • Передавайте ключ идемпотентности в каждый запрос на создание платежа и используйте его при ретраях.
  • Вебхук: проверить подпись, сохранить по уникальному идентификатору, ответить 200, обработать в фоне.
  • Статусы меняйте через машину состояний, дубли отсекайте уникальными индексами в базе.
  • Тестируйте повторы и перестановку событий, раз в сутки сверяйтесь с провайдером.

Частые вопросы

  • Сколько хранить ключи идемпотентности?

    Не меньше, чем длится окно возможных повторов: ретраев клиента, фоновых задач и вебхуков провайдера. Обычно это от суток до нескольких дней; платёжные записи при этом хранятся столько, сколько требует учёт.

  • Что делать, если вебхук так и не пришёл?

    Не полагаться только на вебхуки: для зависших платежей фоновая задача периодически запрашивает статус у провайдера через API. Ежедневная сверка ловит оставшиеся расхождения.

  • Достаточно ли заблокировать кнопку оплаты после клика?

    Нет, это только улучшает интерфейс. Повторы возникают и без клика — из-за сети, ретраев и перезапуска задач, поэтому защита должна быть на сервере и в базе данных.