Содержание
Идемпотентность платежа означает, что повторный запрос на ту же оплату не приводит ко второму списанию, а повторное уведомление банка не меняет статус заказа дважды. Её обеспечивают три вещи: ключ идемпотентности в каждом запросе к платёжному провайдеру, уникальные ограничения в базе и обработка вебхуков, которая спокойно переживает дубли и перестановку событий.
Разберём, откуда берутся двойные списания и как построить платёжный контур, в котором их нет.
Почему возникают двойные списания?
Двойные списания возникают, потому что в сети любой запрос может быть отправлен повторно: пользователь нажал кнопку дважды, клиент не дождался ответа и повторил запрос, фоновая задача упала после успешной оплаты и перезапустилась. Если система каждый такой запрос считает новым, деньги списываются снова.
Типичные источники повторов:
- двойной клик на кнопке «Оплатить» или обновление страницы;
- мобильное приложение повторяет запрос при потере связи;
- фоновая задача перезапускается после сбоя уже после вызова платёжного API;
- платёжный провайдер повторно присылает вебхук, если не получил быстрый ответ 2xx;
- вебхуки приходят не в том порядке, в котором произошли события.
Все эти случаи штатные — их нельзя «исправить», их нужно учитывать в архитектуре.
Что такое ключ идемпотентности?
Ключ идемпотентности — это уникальный идентификатор операции, который клиент передаёт вместе с запросом. Сервер запоминает ключ и результат: если запрос с тем же ключом приходит снова, он возвращает сохранённый результат вместо повторного выполнения. Многие платёжные провайдеры поддерживают такой ключ в заголовке запроса.
Как это устроить у себя:
- Ключ создаётся один раз на операцию — например, при создании попытки оплаты заказа — и сохраняется.
- Все повторы этой операции, включая ретраи фоновых задач, используют тот же ключ.
- На стороне сервера таблица ключей с уникальным индексом хранит статус и результат.
- Если запрос с ключом ещё выполняется, повтор получает ответ «в обработке», а не запускает второй.
| Где нужен ключ | Что защищает | Кто создаёт ключ |
|---|---|---|
| Запрос на создание платежа к провайдеру | От второго списания при ретрае | Ваш бэкенд, один на попытку оплаты |
| Ваш 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';
Как проверить, что платёжный контур надёжен?
Надёжность платёжного контура проверяют тестами на повторы и регулярной сверкой с провайдером. Тесты воспроизводят двойной клик, повторный вебхук и перестановку событий, а сверка раз в сутки находит расхождения, которые пропустила логика.
Чек-лист:
- Тест: два одновременных запроса на оплату одного заказа — одно списание.
- Тест: один вебхук, отправленный трижды, — один переход статуса и одно письмо.
- Тест: вебхук возврата раньше вебхука оплаты — итоговый статус корректен.
- Ежедневная сверка платежей в системе с отчётом провайдера.
- Алерт на расхождения и на вебхуки, которые не удалось обработать.
Общие правила надёжных фоновых задач — повторы, таймауты, идемпотентность — мы разбирали в статье очереди задач в веб-приложении, а проектирование API — в статье как спроектировать REST API.
Коротко
- Повторы запросов и вебхуков — штатная ситуация, архитектура должна их выдерживать.
- Передавайте ключ идемпотентности в каждый запрос на создание платежа и используйте его при ретраях.
- Вебхук: проверить подпись, сохранить по уникальному идентификатору, ответить 200, обработать в фоне.
- Статусы меняйте через машину состояний, дубли отсекайте уникальными индексами в базе.
- Тестируйте повторы и перестановку событий, раз в сутки сверяйтесь с провайдером.
Частые вопросы
Сколько хранить ключи идемпотентности?
Не меньше, чем длится окно возможных повторов: ретраев клиента, фоновых задач и вебхуков провайдера. Обычно это от суток до нескольких дней; платёжные записи при этом хранятся столько, сколько требует учёт.
Что делать, если вебхук так и не пришёл?
Не полагаться только на вебхуки: для зависших платежей фоновая задача периодически запрашивает статус у провайдера через API. Ежедневная сверка ловит оставшиеся расхождения.
Достаточно ли заблокировать кнопку оплаты после клика?
Нет, это только улучшает интерфейс. Повторы возникают и без клика — из-за сети, ретраев и перезапуска задач, поэтому защита должна быть на сервере и в базе данных.



