· 7 мин чтения

Повторные уведомления от платёжных систем: идемпотентность на практике

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

Почему уведомления приходят дважды

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

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

Что страшного в дубле

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

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

Идемпотентный ключ: что взять за идентификатор

Основа всего, это уникальный идентификатор того, что вы обрабатываете. Варианты:

  • идентификатор операции у провайдера (если он стабилен и уникален в рамках провайдера);
  • идентификатор события, если провайдер присваивает его каждому уведомлению;
  • составной ключ: провайдер, идентификатор операции и тип события.

Нужно понимать разницу. Один платёж порождает несколько событий: создан, авторизован, оплачен, возвращён. Если ключом взять только идентификатор операции, второе законное событие (возврат) будет принято за дубль первого. Поэтому ключ должен включать тип события или переход статуса. Какие поля предоставляет конкретный провайдер, смотрите в его документации, я не буду называть их здесь, чтобы не выдумывать.

Атомарная защита на уровне базы данных

Самая надёжная защита, это уникальный индекс. Заведите таблицу обработанных событий:

CREATE TABLE payment_events (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  provider    VARCHAR(32)  NOT NULL,
  event_key   VARCHAR(190) NOT NULL,
  payload     MEDIUMTEXT   NOT NULL,
  received_at DATETIME     NOT NULL,
  processed_at DATETIME    NULL,
  UNIQUE KEY uq_event (provider, event_key)
);

Затем при получении уведомления пытаетесь вставить запись. Если вставка прошла, вы первый, и можно обрабатывать. Если сработало нарушение уникальности, это повтор.

$stmt = $pdo->prepare(
  'INSERT INTO payment_events (provider, event_key, payload, received_at)
   VALUES (:p, :k, :body, NOW())'
);
try {
    $stmt->execute([':p' => $provider, ':k' => $eventKey, ':body' => $rawBody]);
    $isNew = true;
} catch (PDOException $e) {
    if ($e->errorInfo[1] === 1062) { // дубликат ключа в MySQL
        $isNew = false;
    } else {
        throw $e;
    }
}

Преимущество подхода: проверка и запись происходят одной атомарной операцией на стороне СУБД. Схема «сначала SELECT, потом INSERT» уязвима для гонки: два параллельных запроса оба увидят, что записи нет, и оба продолжат.

Что отвечать на повтор? Обычно успешный ответ в формате, который ожидает провайдер, ведь вы уже приняли это событие, и нет смысла заставлять его повторять попытки. Но если первая обработка не завершилась (процесс упал после вставки), повтор должен довести её до конца. Для этого и нужно поле processed_at: задача по расписанию подбирает события, принятые, но не обработанные в течение разумного времени.

Идемпотентность самого действия

Защита на входе не заменяет защиту на уровне бизнес-операции. Хорошо, когда каждое действие, которое можно повторить, само устроено безопасно.

Смена статуса заказа: обновляйте условно, только если текущий статус допускает переход.

UPDATE orders
   SET status = 'paid', paid_at = NOW()
 WHERE id = :id AND status IN ('new', 'pending');

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

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

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

Транзакция вокруг всего

Изменения, которые должны произойти вместе (вставка события, смена статуса заказа, запись платежа), оберните в одну транзакцию. Либо всё применилось, либо ничего.

$pdo->beginTransaction();
try {
    // 1. вставка в payment_events (уникальный ключ)
    // 2. условное обновление заказа
    // 3. запись в payments и постановка задачи на передачу в учёт
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

Побочные эффекты за пределами базы (письма, вызовы внешних сервисов) в транзакцию не включаются. Для них используйте очередь: внутри транзакции вы добавляете задачу, а исполнитель забирает её уже после фиксации. Тем самым вы избегаете ситуации, когда письмо ушло, а транзакция откатилась.

Быстрый ответ и очередь

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

  1. Принять запрос, проверить подпись, записать событие в таблицу.
  2. Выполнить минимальные изменения в рамках одной транзакции.
  3. Вернуть успешный ответ.
  4. Тяжёлую работу сделать отдельным процессом по очереди.

Принцип выбора между мгновенной реакцией и периодической проверкой я разбираю в статье про вебхуки и опрос по расписанию.

Как проверить, что защита работает

Проверка идемпотентности это отдельный тест, и забывать о нём нельзя.

  • Отправьте одно и то же тело уведомления пять раз подряд. Должны остаться одна запись события, одна смена статуса, один документ.
  • Отправьте те же пять запросов параллельно, например скриптом. Если результат тот же, защита на уровне базы работает.
  • Прервите обработку посередине (искусственно выбросите исключение после вставки события) и повторите запрос. Событие должно быть доведено до конца, а не потеряно как «уже виденное».
  • Проверьте разные типы событий по одной операции: «оплачен» и затем «возврат» не должны блокировать друг друга.

Полный набор сценариев вы найдёте в статье про тестирование платёжной интеграции.

Что записывать в журнал

Идемпотентность хорошо сочетается с журналом, потому что повтор перестаёт быть загадкой. Для каждого входящего уведомления полезно сохранять: время получения, ключ события, результат проверки подписи, решение (новое событие или повтор), итог обработки и причину ошибки, если она была. Тогда на вопрос «почему этот заказ в таком статусе» можно ответить за минуту, просто открыв записи по идентификатору платежа.

Отдельно стоит считать число повторов. Если дублей внезапно стало заметно больше обычного, это сигнал, что ваш обработчик отвечает слишком медленно или возвращает ошибки, и провайдер настойчиво пытается достучаться. Такой показатель легко вывести в панель мониторинга.

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

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

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

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

Что отвечать провайдеру на повторное уведомление?

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

Как долго хранить таблицу событий?

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

Нужна ли идемпотентность при запросах от меня к провайдеру?

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

Если вам нужна устойчивая приёмка платежей с понятным журналом, посмотрите страницу об интеграциях платёжных систем или напишите в Telegram.