Повторные платежи в интернет-сервисе: откуда берутся дубли и как их предотвратить
Покупатель нажал «Оплатить», браузер отправил запрос, а ответ задержался. Человек обновил страницу и нажал кнопку ещё раз. Сервер не знает, успел ли платёжный провайдер создать первую операцию. Если обработать повтор как новую команду, у заказа появятся две платёжные попытки.
В кейсе OGPixelPay описан похожий риск: при повторном открытии checkout могла появиться новая независимая операция, хотя активная уже существовала. Кейс подтверждает, что эту логику отдельно дорабатывали. Он не раскрывает используемого провайдера, схему базы данных или точный код исправления. Поэтому ниже я разбираю общую архитектуру и помечаю код как пример, а не как описание реализации OGPixelPay.
Четыре вещи, которые называют «дублем»
Словом «дубль» часто обозначают разные события:
- Повторный HTTP-запрос. Браузер или клиентская библиотека отправили тот же запрос ещё раз.
- Повторная платёжная попытка. Сервис создал ещё одну запись или checkout-сессию для того же заказа.
- Повторное списание. Провайдер реально провёл деньги дважды. Две записи в базе сами по себе не доказывают два списания.
- Повторное бизнес-действие. Сервис второй раз выдал товар, баланс или услугу после одного подтверждённого платежа.
Защищать нужно все уровни, но разными средствами. Блокировка кнопки касается только интерфейса. Ключ провайдера помогает при повторном API-запросе на создание платежа. Уникальное ограничение в базе защищает от параллельных записей. Обработчик webhook должен повторно видеть то же событие как уже обработанное.
Идемпотентность простыми словами
Идемпотентная операция при повторе даёт тот же итог, что и при первом выполнении. Например, если сервер дважды получит команду «создать оплату для заказа 482», второй запрос должен вернуть ту же активную попытку, а не создать новую.
Для этого сервер создаёт ключ операции и сохраняет его вместе с платёжной попыткой. При сетевом повторе он использует тот же ключ. Если клиент генерирует новый случайный ключ на каждый клик, защита не работает: для провайдера каждый ключ означает новую команду.
Ключ надо связать с конкретным намерением оплатить конкретный заказ. При новой осознанной попытке после окончательного отказа создаётся новая платёжная попытка и новый ключ. Нельзя повторно использовать один ключ для разных заказов или сумм. Срок хранения ключа, повтор результата и реакция на параметры, изменившиеся между запросами, зависят от провайдера. Перед внедрением сверяйтесь с его документацией. Например, Stripe описывает идемпотентные POST-запросы в собственной API-документации; это пример поведения одного поставщика, а не свидетельство о провайдере OGPixelPay.
Сценарий оплаты на сервере
Полезно хранить заказ отдельно от платёжных попыток. У одного заказа со временем может быть несколько последовательных попыток, но одновременно должна быть одна активная попытка, если это соответствует правилам бизнеса и провайдера.
Упрощённый вариант состояний:
Заказ:
черновик
→ ожидает оплаты
→ оплачен
→ выполняется
→ завершён
Платёжная попытка:
создаётся → ожидает клиента
├→ подтверждена
├→ отклонена или отменена
└→ результат неизвестен
Это не универсальная машина состояний провайдера. Конкретные статусы и допустимые переходы надо сверить с его API. Состояние «неизвестен результат» полезно после таймаута: провайдер мог создать платёж, хотя сервис не получил ответ.
1. Создать или найти попытку
Сервер проверяет пользователя, заказ, сумму и валюту. Затем он блокирует заказ в транзакции или делает атомарную операцию, чтобы два запроса одновременно не создали две активные попытки. Если заказ уже оплачен, checkout не создаётся. Если активная попытка есть, сервер возвращает её текущий результат.
На уровне базы можно добавить частичный уникальный индекс. Такой индекс может ограничивать только строки, подходящие под условие WHERE, как показано в документации PostgreSQL. Названия статусов ниже примерные:
CREATE UNIQUE INDEX
one_open_payment_per_order
ON payment_attempts (order_id)
WHERE status IN (
'creating',
'pending',
'requires_action',
'unknown'
);
Уникальное ограничение закрывает гонку между двумя запросами, даже если оба почти одновременно не нашли активную запись. При конфликте код должен перечитать существующую попытку и продолжить с ней. Список активных статусов зависит от того, как проект трактует отмену и таймауты.
2. Повторить внешний вызов с тем же ключом
После создания записи в базе сервер вызывает провайдера с сохранённым ключом операции. Если соединение оборвалось после отправки запроса, повторный вызов должен использовать тот же ключ. Когда провайдер поддерживает идемпотентность для этой операции, он может вернуть прежний результат. Это поведение нужно проверить по документации конкретного API: у провайдеров различаются срок хранения ключа, поддерживаемые методы и обработка ошибок.
Если провайдер не поддерживает подходящий ключ, таймаут нельзя трактовать как доказательство того, что платежа нет. Сначала найдите попытку по внешнему номеру заказа или запросите статус. Если провайдер не позволяет надёжно найти операцию, оставьте её в состоянии сверки и не создавайте следующую вслепую.
Следующий пример предполагает, что API провайдера поддерживает идемпотентный ключ при создании платежа. Без такой поддержки повтор внешнего вызова надо заменить сверкой статуса, пока результат не выяснен.
Ниже схема на TypeScript. Она показывает порядок решений, но опускает авторизацию, обработку ошибок, конкретный SDK и транзакционный API используемой базы.
async function openCheckout(
orderId: string,
userId: string
) {
const newAttemptId =
crypto.randomUUID();
const state = await db.transaction(
async (tx) => {
const order =
await tx.getOrderForUpdate(
orderId,
userId
);
if (!order) {
throw new Error(
"Заказ не найден"
);
}
if (order.status === "paid") {
return {
kind: "paid" as const
};
}
const current =
await tx.findActivePayment(
orderId
);
if (current) {
return {
kind: "attempt" as const,
current
};
}
const created =
await tx.addAttempt({
id: newAttemptId,
orderId,
amount: order.amount,
currency: order.currency,
status: "creating",
idempotencyKey:
"payment-attempt:" +
newAttemptId
});
return {
kind: "attempt" as const,
current: created
};
}
);
if (state.kind === "paid") {
return { status: "paid" };
}
const result =
await provider.createPayment({
orderId,
amount: state.current.amount,
currency:
state.current.currency,
merchantReference:
state.current.id,
idempotencyKey:
state.current.idempotencyKey
});
await db.saveProviderResponse(
state.current.id,
result
);
return result;
}
Пример ключа: payment-attempt:<attempt-id>. Точную форму нужно сверить с правилами провайдера. Сервер сохраняет ключ вместе с попыткой, а повторный HTTP-запрос использует сохранённое значение. Ответ провайдера нужно записать без отката уже подтверждённого статуса, если webhook пришёл раньше. После подтверждённого отказа новая попытка получит новый ID и ключ. При конкурентных запросах второй запрос либо получает существующую попытку, либо обрабатывает конфликт уникальности. Не выполняйте сетевой вызов внутри долгой транзакции с блокировкой строки.
3. Подтвердить оплату по webhook
Возврат браузера на страницу «Спасибо» не доказывает успешное списание. Пользователь может открыть такой адрес вручную, а ответ браузера может потеряться. Сервер должен получать подтверждение от провайдера, сверять подпись по его алгоритму и проверить, что идентификатор платежа, заказ, сумма и валюта совпадают с ожидаемыми.
Доставка webhook может повторяться, если провайдер не получил своевременный успешный ответ. Stripe, например, документирует автоматические повторы доставки и рекомендует отслеживать ID событий и дубли. Другие API могут использовать свои подписи, заголовки и интервалы. Следуйте документации выбранного провайдера.
Обработку входящего события и изменение заказа нужно зафиксировать атомарно. Базовый шаблон выглядит так:
CREATE TABLE
processed_provider_events (
provider text NOT NULL,
event_id text NOT NULL,
processed_at timestamptz
NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
В одной транзакции вставьте ID события с защитой от конфликта. Если запись уже существует, завершите обработку без повторного бизнес-действия. Если вставка прошла, проверьте допустимость перехода состояния и зафиксируйте новую запись события вместе с изменением платежа и заказа. При ошибке транзакцию надо откатить, чтобы провайдер мог повторить доставку.
Ниже показан эскиз обработки webhook. Предварительно сохранённый ID попытки передаётся провайдеру как merchant reference или metadata, если он поддерживает такое поле. Тогда ранний webhook можно сопоставить с попыткой до сохранения ответа checkout. Если провайдер не возвращает такой идентификатор, сохраните событие в отдельном inbox и повторите сопоставление после записи ответа. Название и возможности поля зависят от провайдера.
recordEventIfNew вставляет ID события с проверкой уникальности. Повтор того же события не выполняет бизнес-действие второй раз. Тяжёлую выдачу следует оставить воркеру, а обработчик быстро фиксирует изменение и outbox-команду, после чего подтверждает доставку.
async function receiveWebhook(
rawBody: Buffer,
signature: string
) {
const event =
verifyAndParse(rawBody, signature);
const result = await db.transaction(
async (tx) => {
const first =
await tx.recordEventIfNew(
event.provider,
event.id
);
if (!first) return "duplicate";
const reference =
extractReference(event);
const attempt =
await tx.lockAttemptByReference(
reference
);
assertMatches(attempt, event);
const transition =
await tx.applyAllowedTransition(
attempt,
event
);
if (transition.becamePaid) {
await tx.enqueueOnce(
"fulfill-order",
attempt.orderId
);
}
return "processed";
}
);
return acknowledgeWith2xx(
result
);
}
Имена функций здесь условные. Заказ попадает в очередь выдачи только после подтверждённого перехода в статус оплаты. Отказ или отмена не запускают выдачу. Команду для очереди нужно записать в transactional outbox в той же транзакции, что и изменение статуса. Это снижает риск, что база сохранит оплату, а сообщение не попадёт в очередь; AWS описывает такой шаблон. Воркер тоже должен безопасно переживать повторы, используя ключ заказа.
Проверки только по event_id недостаточно для всех случаев: иногда один и тот же платёж приходит в двух разных событиях с разными ID. Stripe отдельно описывает такой случай и рекомендует сопоставлять ID объекта и тип события (документация). Поэтому статус заказа меняют только допустимым переходом, а успешная выдача запускается только при переходе в статус оплачено. Запрос статуса и webhook могут прийти одновременно; оба пути должны привести к одному состоянию.
Подпись проверяют по исходному телу запроса и по правилам конкретного провайдера. OWASP отмечает, что подпись подтверждает отправителя, но сама по себе не защищает от повторной обработки. Если сервис пропустил событие или не получил ответ вовремя, нужна сверка состояния через API или очередь повторной обработки. Обработчик должен быстро сохранить событие и вернуть подтверждение; тяжёлую выдачу выполняет воркер отдельно, как рекомендует Stripe.
Когда можно открыть следующую попытку
Попытка освобождает место для новой только после подтверждённого конечного состояния, например отказа или отмены. Если конкретный провайдер закрывает попытку по сроку, учитывайте его подтверждённый конечный статус. Локальный таймаут не означает, что попытка завершилась. Пока статус неизвестен, сервис должен оставить её активной и сверить с провайдером. Если отмена зависит от внешней системы, сначала проверьте результат отмены. Храните предыдущую попытку для разбора поддержки, а новую создавайте с новым ID и ключом.
Что должно произойти при сбое
| Сценарий | Ожидаемое поведение |
|---|---|
| Двойной клик по оплате | Оба запроса находят одну активную попытку |
| Клиент обновил checkout | Сервер возвращает текущую попытку |
| Первый запрос к провайдеру завершился таймаутом | Попытка получает статус для сверки; новый платёж вслепую не создаётся |
| Два запроса пришли одновременно | Ограничение БД оставляет одну активную попытку |
| Заказ уже оплачен | Сервер не открывает новую оплату |
| Один webhook доставлен повторно | Второй вызов не выдаёт услугу повторно |
| Два разных события подтверждают одну оплату | Бизнес-действие выполняется один раз |
| Webhook пришёл раньше ответа checkout | Сервис сохраняет подтверждение и не откатывает paid назад |
| Сумма или валюта не совпали | Событие не переводит заказ в paid; случай попадает в разбор |
| Провайдер временно недоступен | Сервис сохраняет состояние и позже сверяет платёж по API |
Что не защищает от дублей
- Отключить кнопку после клика. Это улучшает интерфейс, но не останавливает повтор после перезагрузки, второго устройства или сетевого повтора.
- Хранить флаг только в React. После перезагрузки вкладки состояние исчезнет, а сервер всё равно должен принимать решение.
- Считать HTTP 200 подтверждением списания. Ответ может означать только то, что провайдер принял запрос или создал форму оплаты. Точный смысл ответа зависит от API.
- Обрабатывать каждый webhook как новую команду. Повторная доставка нормальна. Переход статуса и выдача должны быть повторяемыми без повторного эффекта.
- Создавать платёж при каждом входе в checkout. Открытие страницы только читает состояние заказа. Само по себе оно не означает новое намерение списать деньги.
Как проверить свою интеграцию
Перед релизом прогоните хотя бы такие сценарии на тестовом окружении провайдера:
- Дважды нажать «Оплатить» в одной вкладке.
- Открыть checkout в двух вкладках.
- Отправить два запроса создания платежа одновременно.
- Обновить страницу, когда платёж ещё ожидает подтверждения.
- Имитировать потерю ответа после создания платежа.
- Повторить запрос с тем же ключом и теми же параметрами.
- Проверить реакцию на тот же ключ с другой суммой.
- Доставить одно и то же событие webhook дважды.
- Доставить два события об одном платеже с разными ID.
- Отправить событие с неверной подписью.
- Передать правильную подпись, но чужой ID заказа, сумму или валюту.
- Остановить обработчик после сохранения события, но до выполнения заказа.
- Прислать событие подтверждения раньше ответа на запрос checkout.
- Проверить, что повторный запуск задачи в очереди не дублирует выдачу.
- Сверить неизвестный платеж после таймаута с API провайдера.
Тестовая среда и поведение sandbox не всегда полностью совпадают с реальными повторами. Перед запуском сверьте параметры и ограничения провайдера, затем оставьте журнал попыток и понятный способ сверки для поддержки.
В OGPixelPay кейс подтверждает доработку сценария повторного открытия checkout, но не публичную схему хранения или конкретного провайдера. Я использую его как пример задачи, а не как доказательство, что один и тот же код подойдёт любому магазину. Если у вашего сервиса появляются новые платежные попытки при возврате к оформлению заказа, сначала проверьте состояние заказа на сервере и наличие активной попытки. Описание проекта OGPixelPay.
Источники
- Stripe API: идемпотентные запросы (пример поведения Stripe, провайдер OGPixelPay публично не указан)
- Stripe: доставка событий, повторы и дубликаты webhook
- OWASP: безопасность webhook
- Кейс OGPixelPay