Со стороны пользователя онлайн-оплата выглядит почти мгновенной.
Он нажимает:
«Оплатить»
переходит на страницу банка или платёжного сервиса, подтверждает операцию и через несколько секунд видит:
Оплата прошла успешно.
Кажется, что техническая реализация должна выглядеть примерно так же просто:
Нажали «Оплатить»
↓
Получили деньги
↓
Заказ оплаченНо внутри серьёзного веб-продукта между первым и последним пунктом может находиться целая распределённая система.
Пользователь способен нажать кнопку дважды.
Сетевой запрос может завершиться timeout уже после того, как платёжный провайдер создал операцию.
Пользователь может закрыть вкладку после оплаты и никогда не вернуться на сайт.
Webhook может прийти раньше браузера.
Или позже.
Или несколько раз.
После успешного списания может упасть отправка email.
После оплаты подписки может временно быть недоступна база прав доступа.
А спустя неделю клиент может запросить возврат.
Поэтому для SaaS, интернет-магазина, CRM с оплатами или любого сервиса с коммерческими операциями платежи нельзя проектировать как:
if (success) {
order.status = 'paid';
}Надёжный платёжный контур должен исходить из совершенно другой реальности:
сетевые запросы могут повторяться, ответы могут теряться, события могут приходить не в ожидаемом порядке, а одна бизнес-операция может состоять из нескольких независимых технических действий.
Разберём, как проектировать такую систему.
Сначала разделим заказ и платёж
Одна из первых архитектурных ошибок — хранить всё в одном поле:
order.statusНапример:
NEW
PAYMENT_PENDING
PAID
PAYMENT_FAILED
REFUNDED
SHIPPEDПроблема в том, что здесь смешаны две разные сущности.
Заказ отвечает на вопрос:
Что приобрёл пользователь?
Платёж:
Что произошло с попыткой оплаты?
У одного заказа может быть несколько попыток оплаты.
Например:
Заказ #1842
│
├── Payment #1
│ └── FAILED
│
├── Payment #2
│ └── CANCELED
│
└── Payment #3
└── SUCCEEDEDПервые две ошибки не означают, что заказ должен исчезнуть.
Пользователь просто попробовал другой способ оплаты.
Поэтому практичнее иметь как минимум:
Orderи отдельно:
PaymentУ заказа и платежа разные жизненные циклы
Упрощённый жизненный цикл заказа может быть таким:
DRAFT
↓
AWAITING_PAYMENT
↓
PAID
↓
FULFILLING
↓
FULFILLEDА платежа:
CREATED
↓
PENDING
↙ ↘
FAILED SUCCEEDEDТакже могут существовать:
CANCELEDи отдельные сущности возвратов.
Это важное разделение.
Если первая попытка:
FAILEDзаказ всё ещё:
AWAITING_PAYMENTи клиент может попробовать ещё раз.
Нажатие «Оплатить» ещё не является платежом
Начнём с браузера.
Пользователь нажимает:
[Оплатить]Frontend отправляет backend:
POST /api/orders/1842/payОчень важно, чтобы браузер не присылал authoritative стоимость.
Плохой вариант:
{
"orderId": 1842,
"amount": 100
}если сервер просто доверяет:
amount = 100Пользователь контролирует браузер.
Он может изменить запрос на:
{
"orderId": 1842,
"amount": 1
}Стоимость должен определять сервер.
Цена формируется на backend
Backend получает:
orderId = 1842и сам проверяет:
кому принадлежит заказ;
можно ли его оплачивать;
какие позиции входят;
какая стоимость зафиксирована;
какая валюта;
не истекло ли предложение;
не оплачен ли заказ ранее.После этого сервер получает итоговую сумму:
Order #1842
Товар A:
2 × 1 500 ₽
Товар B:
1 × 2 900 ₽
Итого:
5 900 ₽И именно:
5 900 ₽отправляется платёжному провайдеру.
Цена заказа должна быть зафиксирована
Представим интернет-магазин.
Сегодня товар стоит:
5 000 ₽Клиент оформил заказ.
Через час администратор изменил цену товара:
5 500 ₽Если при оплате система снова посмотрит:
product.currentPriceполучится, что сумма уже оформленного заказа изменилась.
Поэтому заказ обычно хранит snapshot коммерческих условий.
Например:
OrderItem
productId
title
quantity
unitPrice
tax
totalНе только:
productIdЦена заказа отвечает:
На каких условиях клиент оформил конкретную покупку?
А текущая цена товара:
Сколько товар стоит сейчас?
Это разные данные.
Денежные значения нельзя считать обычным JavaScript float
Для денег нужно использовать модель с фиксированной точностью.
Например:
5900.00 RUBили минимальные денежные единицы:
590000 копеекв зависимости от архитектуры и требований конкретного API.
Не стоит строить финансовую логику вокруг обычных floating-point вычислений:
0.1 + 0.2Финансовое состояние должно быть детерминированным.
Перед обращением к провайдеру полезно создать локальную попытку платежа
Вместо схемы:
вызвали платёжный API
↓
потом что-нибудь записали в БДлучше сначала зафиксировать собственное намерение.
Например:
PaymentAttempt
id:
pay_local_7fd...
orderId:
1842
amount:
5900.00
currency:
RUB
status:
CREATEDТеперь у системы уже существует внутренний идентификатор операции.
После этого можно обращаться к платёжному провайдеру.
Зачем это нужно
Представим:
POST → платёжный провайдеруспешно создал платёж.
Провайдер уже знает:
payment_id = p_839...Но соединение оборвалось до получения ответа вашим backend.
С точки зрения приложения:
timeoutС точки зрения платёжного сервиса:
платёж созданЕсли бездумно повторить запрос, потенциально можно создать ещё одну операцию.
Это один из центральных конфликтов платёжной архитектуры:
ошибка сетевого запроса не доказывает, что операция не выполнилась.
Для этого существует идемпотентность
Идемпотентность в данном контексте означает:
Повторное выполнение одного и того же намерения не должно создавать новый бизнес-результат.
Допустим, мы генерируем:
paymentOperationId =
7a41...и используем его как:
Idempotency-Keyпри создании платежа.
Первый запрос:
create payment
key = 7a41создал операцию.
Ответ потерялся.
Backend повторяет:
create payment
key = 7a41Платёжная система может распознать:
Это повтор той же операции.
а не:
Создадим ещё одну.
Очень важно: один ключ на бизнес-операцию, а не на HTTP-запрос
Плохая реализация:
Попытка №1:
key = UUID-A
timeout
Попытка №2:
key = UUID-BДля провайдера это две разные операции.
Правильнее:
Операция оплаты заказа #1842
↓
key = UUID-AИ все технические retries этой операции используют:
UUID-AНовый ключ появляется только тогда, когда пользователь действительно создаёт новую попытку оплаты.
Идемпотентность нужна не только при обращении к платёжному провайдеру
Представим пользователя, который дважды быстро нажимает:
[Оплатить]
[Оплатить]Frontend может отправить два запроса.
Или браузер решил повторить запрос.
Или мобильная сеть оборвалась, а приложение повторило действие.
Поэтому собственный endpoint:
POST /checkoutтоже полезно проектировать идемпотентным.
Например:
Checkout operation:
checkout_a13...Backend видит второй запрос и возвращает уже существующую попытку вместо создания новой.
Но idempotency key — не единственная защита
Надёжная система обычно имеет несколько уровней.
Например:
idempotency key
+
unique constraint
+
state machine
+
transactionЕсли бизнес-правило говорит:
Один конкретный платёж не может дважды оплатить один заказ,
это полезно фиксировать не только условием в коде.
В подходящем месте можно иметь database constraint или уникальный ключ.
После создания платежа пользователь отправляется на оплату
Провайдер может вернуть:
paymentId
confirmationUrlили данные для SDK.
Backend сохраняет внешний ID:
localPaymentId:
pay_local_7fd
providerPaymentId:
p_839...
status:
PENDINGFrontend получает безопасные данные, необходимые для продолжения.
После этого пользователь подтверждает оплату у платёжного провайдера.
Самая опасная ошибка — считать success URL подтверждением оплаты
После оплаты провайдер может вернуть браузер:
https://shop.example/payment/successОчень легко написать:
onSuccessPage(() => {
order.status = 'PAID';
});Так делать нельзя.
Сам факт того, что пользователь открыл:
/payment/successне является надёжным доказательством движения денег.
URL можно открыть вручную.
Параметры браузера можно изменить.
Пользователь вообще может не вернуться после успешной оплаты.
Redirect отвечает за UX, а не за финансовую истину
Success page должна говорить примерно:
Проверяем статус оплаты…
А затем запросить backend:
GET /api/orders/1842Backend уже знает authoritative payment state.
Возможны варианты:
PAID→ показать успех.
AWAITING_PAYMENT→ продолжить ожидание.
PAYMENT_FAILED→ предложить новую попытку.
То есть:
browser redirectотвечает за пользовательский интерфейс.
А не за подтверждение денег.
Главный канал подтверждения — серверное событие
После изменения состояния платёжный провайдер отправляет webhook.
Упрощённо:
Payment Provider
↓
POST /webhooks/payment
↓
BackendНапример:
{
"event": "payment.succeeded",
"paymentId": "p_839..."
}Теперь backend может сопоставить внешний платёж:
p_839...со своей записью:
PaymentAttempt #...и заказом:
Order #1842Webhook должен считаться недоверенным входным запросом
Нельзя писать:
if (req.body.event === 'paid') {
markOrderPaid();
}только потому, что endpoint называется:
/webhookОн доступен через интернет.
Поэтому необходимо использовать предусмотренный конкретным платёжным провайдером механизм проверки подлинности.
Это может быть:
криптографическая подпись;
проверка источника;
повторный authenticated запрос
к API провайдера;или комбинация методов.
Главное правило:
нельзя позволять произвольному HTTP-запросу объявлять заказ оплаченным.
Почему полезна дополнительная проверка состояния у провайдера
Представим webhook сообщает:
payment.succeededBackend может дополнительно запросить:
GET provider/payment/p_839через серверную аутентификацию и получить текущее authoritative состояние.
После этого проверить:
paymentId
amount
currency
status
merchant/accountИ только затем применять изменение.
Это особенно полезно, когда такой способ проверки предусмотрен интеграцией конкретного провайдера.
Сумма webhook тоже должна совпадать
Даже настоящий платёж нельзя автоматически связать с заказом только по факту:
payment succeededНужно убедиться:
Order:
5900 RUBPayment:
5900 RUBа не:
Payment:
59 RUBМинимальный invariant:
provider payment
соответствует
ожидаемому order/payment attemptпо идентификатору, сумме, валюте и другим критичным атрибутам конкретной интеграции.
Webhook может прийти несколько раз
Это не редкий edge case, который можно игнорировать.
Системы доставки событий обычно строятся так, чтобы важное уведомление можно было повторить, если принимающая сторона не подтвердила обработку.
Поэтому нельзя исходить из:
один платёж
=
один webhookНужно исходить из:
один платёж
=
один или несколько webhookСамый простой опасный обработчик
Например:
if (event === 'payment.succeeded') {
await grantSubscription();
await sendEmail();
await createInvoice();
}Webhook приходит второй раз.
Система снова:
выдаёт подписку;
отправляет email;
создаёт документ.Для email это просто раздражает.
Для:
начислить бонус;
выдать товар;
создать лицензию;
увеличить баланспоследствия могут быть значительно серьёзнее.
Webhook processing должен быть идемпотентным
Если провайдер имеет стабильный:
eventIdего можно сохранять.
Например:
payment_events
provider
event_id
type
payment_id
received_atс уникальным ограничением:
UNIQUE(provider, event_id)Первое событие:
INSERT → successПовтор:
UNIQUE violationСистема понимает:
Это событие уже обрабатывалось.
И может спокойно вернуть успешный HTTP-ответ, не выполняя бизнес-действия второй раз.
Но deduplication по event ID всё равно недостаточно
Иногда разные события способны сообщить одну и ту же бизнес-информацию.
Или система сама повторно запрашивает состояние платежа.
Поэтому полезна вторая защита:
идемпотентность самой бизнес-операции.
Например:
Order #1842уже:
PAIDПри повторном подтверждении:
markAsPaid()не должно снова выполнять:
выдать товар;
начислить бонус;
создать подписку.Именно поэтому статус заказа должен меняться через state machine
Например:
AWAITING_PAYMENT
↓
PAIDДопустим только один раз.
Повтор:
PAID → PAIDне является новой оплатой.
Система может считать его harmless повтором.
Получаем две защиты:
Event deduplication
+
Business-state idempotencyЭто намного надёжнее одной проверки.
«Exactly once» — опасное обещание
В распределённых системах гораздо практичнее исходить из того, что транспорт предоставляет события как минимум один раз, а приложение должно уметь безопасно переживать повтор.
То есть вместо надежды:
Webhook всегда придёт ровно один раз.
мы строим:
Он может прийти повторно, но повтор не изменит бизнес-результат.
Это намного более реалистичная модель.
Webhook может прийти раньше пользователя
Обычный сценарий:
пользователь подтверждает оплату
↓
provider отправляет webhook
↓
backend переводит заказ в PAID
↓
пользователь возвращается на сайтОтлично.
Success page сразу видит:
PAIDНо возможен и обратный порядок
пользователь подтверждает оплату
↓
браузер вернулся на сайт
↓
webhook ещё не пришёлFrontend видит:
AWAITING_PAYMENTЭто не обязательно ошибка.
Можно показать:
Платёж обрабатывается. Обычно это занимает несколько секунд.
И периодически обновлять статус.
Через некоторое время webhook приходит:
PAIDИнтерфейс обновляется.
Поэтому success page должна уметь существовать в промежуточном состоянии
Не только:
✅ Оплаченоили:
❌ ОшибкаНо и:
⏳ Проверяем оплатуЭто честнее и технически правильнее.
Что делать, если webhook вообще не пришёл
Нельзя строить финансовую систему, где одна потерянная доставка навсегда оставляет заказ:
PENDINGПоэтому полезен reconciliation.
Например, background job периодически ищет:
Payments
status = PENDING
age > N minutesи спрашивает провайдера:
Каково реальное состояние этого платежа?
Получает:
SUCCEEDEDи приводит локальные данные в соответствие.
Webhook и reconciliation решают разные задачи
Webhook даёт:
быструю реакциюReconciliation:
восстановление консистентностиПолезная модель:
webhook
→ основной быстрый путь
provider API reconciliation
→ страховочная сеткаХорошая платёжная система допускает временное расхождение, но не вечное
Например:
Provider:
SUCCEEDED
Local:
PENDINGможет существовать несколько секунд или минут при проблеме доставки.
Но не должно существовать бесконечно.
Система обязана иметь механизм:
eventual reconciliationWebhook endpoint должен отвечать быстро
Плохой обработчик:
получить webhook
↓
создать PDF
↓
отправить 5 email
↓
обновить CRM
↓
выдать подписку
↓
обновить аналитику
↓
ответить HTTP 200Если SMTP зависнет на 30 секунд, провайдер может решить, что webhook не доставлен, и повторить его.
Гораздо лучше:
Webhook
↓
Проверить
↓
Записать событие
↓
Зафиксировать изменение состояния
↓
Создать background jobs/outbox
↓
COMMIT
↓
HTTP 200А тяжёлые побочные действия выполняются после.
Что такое outbox и зачем он платежам
Представим:
Payment = SUCCEEDEDМы в транзакции записали:
Order = PAIDПосле commit собираемся отправить задачу:
grantSubscription()Но процесс падает именно между этими действиями.
Получилось:
заказ оплаченно:
подписка не выданаDatabase outbox решает этот разрыв
В одной транзакции:
BEGINмы делаем:
Order → PAIDи:
Outbox event:
ORDER_PAIDзатем:
COMMITТеперь либо сохранилось всё:
PAID + событиелибо ничего.
Worker позже читает outbox:
ORDER_PAIDи выполняет:
выдать подписку;
отправить email;
создать чек/документ;
уведомить CRM.Если worker упал, событие остаётся и будет повторено.
А worker тоже должен быть идемпотентным
Потому что:
ORDER_PAIDможет быть обработан повторно.
Например:
grantSubscription(
orderId = 1842
)должен проверить:
entitlement уже существует?Если да:
ничего не создавать второй разПолучается цепочка идемпотентности:
Checkout
↓
Provider API
↓
Webhook
↓
Order transition
↓
Outbox
↓
Worker
↓
Business effectНадёжная архитектура должна продумать повтор на каждом участке, а не только на входе.
Рассмотрим классический сценарий двойного клика
Пользователь:
[Оплатить]не видит мгновенной реакции и нажимает ещё раз.
Получаем:
Request A
Request BЕсли оба одновременно проверяют:
order.paid === falseи затем создают два платежа, простой if не спасает.
Здесь появляется race condition.
Проверять нужно атомарно
Например, в транзакции:
lock orderили использовать уникальный business key / revision.
Логика:
Order #1842
↓
есть активная payment attempt?Если:
давозвращаем существующую.
Если:
нетсоздаём одну.
Важно, чтобы два параллельных процесса не смогли одновременно решить:
Активной попытки нет.
Иногда несколько попыток оплаты всё-таки нужны
Например первая:
FAILEDТогда клиент создаёт вторую:
PaymentAttempt #2Поэтому правило обычно не:
У заказа может существовать только один payment.
А что-то ближе к:
Нельзя одновременно создать две одинаковые активные попытки в рамках одного пользовательского действия.
Точная модель зависит от продукта.
Timeout — самая интересная ошибка
Backend отправил провайдеру:
Create paymentProvider выполнил операцию.
Но ответ потерялся.
Backend видит:
ETIMEDOUTЕсть три возможных истины:
1. запрос не дошёл;
2. дошёл, но операция не выполнилась;
3. операция выполнилась,
но ответ не дошёл.По одному timeout определить вариант нельзя.
Именно поэтому dangerous retry:
создать ещё один платёжнедопустим.
Нужно повторять то же намерение с тем же idempotency key или выяснять состояние уже созданной операции предусмотренным API способом.
Платёжный endpoint должен различать техническую ошибку и бизнес-результат
Например:
Карта отклоненаэто не:
500 Internal Server ErrorЭто нормальный бизнес-результат попытки оплаты.
А:
Payment provider unavailableуже инфраструктурная проблема.
Frontend должен различать:
FAILED
→ можно предложить другой способ
PENDING
→ ждём
TEMPORARY_ERROR
→ можно безопасно повторить
SUCCEEDED
→ заказ оплаченСваливать всё в:
Произошла ошибка
не очень полезно.
Платёж не должен автоматически означать выполненный заказ
Представим интернет-магазин.
Деньги получены.
Но товар ещё:
не собран;
не отправлен;
не доставлен.Поэтому:
Payment = SUCCEEDEDне равно:
Order = FULFILLEDПравильнее:
Payment SUCCEEDED
↓
Order PAID
↓
FULFILLING
↓
FULFILLEDСмешивание финансового и операционного состояния потом создаёт серьёзные проблемы.
Для SaaS действует тот же принцип
Например:
Payment = SUCCEEDEDдолжен создать entitlement:
Plan PRO
validUntil ...Но статус платежа не является самой подпиской.
Платёж отвечает:
Деньги получены.
Entitlement:
К каким функциям пользователь имеет доступ и до какого момента?
Это разные сущности.
Особая ситуация — recurring payment
В подписочном SaaS возникает новый жизненный цикл:
Subscription
│
├── ACTIVE
├── PAST_DUE
├── CANCELED
└── EXPIREDи множество платежей:
Payment January
Payment February
Payment MarchОдин неуспешный очередной платёж не должен переписывать всю историю подписки.
Он запускает бизнес-правило:
ACTIVE
↓
PAST_DUEили grace period.
Точная политика зависит от продукта.
Но опять видно, почему payment.status нельзя использовать как универсальный статус всего бизнеса.
Возврат — тоже отдельная операция
Плохая модель:
payment.status = REFUNDEDи всё.
Что если возврат частичный?
Например:
Payment:
10 000 ₽
Refund #1:
2 000 ₽
Refund #2:
1 000 ₽Платёж существовал и был успешным.
После него появились отдельные финансовые операции возврата.
Практичнее иметь:
Paymentи:
Refundа агрегированное состояние рассчитывать отдельно.
Возврат тоже должен быть идемпотентным
Администратор нажал:
[Вернуть 2 000 ₽]браузер завис.
Нажал ещё раз.
Нельзя создать:
2 000 + 2 000если намерение было вернуть только одну сумму.
Поэтому refund operation тоже получает собственный:
idempotency keyи внутреннюю запись до обращения к провайдеру.
Нельзя возвращать больше оплаченного
Простой invariant:
sum(successful refunds)
<=
captured payment amountПри конкурентных возвратах проверка должна быть атомарной.
Иначе два администратора могут одновременно увидеть:
доступно к возврату: 5 000 ₽и каждый вернуть:
4 000 ₽Отмена и возврат — разные операции
Если платёж ещё:
PENDINGего иногда можно отменить.
Если деньги уже:
SUCCEEDEDпосле этого может потребоваться refund.
Продукт не должен скрывать это различие за одной универсальной кнопкой:
Отменить оплатуесли финансово это разные процессы.
Что делать с заказом, который истёк до завершения платежа
Интересный edge case.
Заказ действовал:
15 минутПользователь начал оплату на 14-й минуте.
Провайдер сообщил успех на 16-й.
Теперь:
Order = EXPIRED
Payment = SUCCEEDEDСистема должна заранее иметь policy.
Например:
если payment был создан
до expiration
→ принять оплатуили:
признать конфликт
→ инициировать возвратУниверсального ответа нет.
Но отсутствие решения приводит к ручным инцидентам.
В платёжной системе важны запрещённые состояния
Например, система никогда не должна позволять:
PAID
но
paidAmount = 0или:
FULFILLED
но
payment = FAILEDесли продукт требует предоплату.
Или:
REFUNDED
но
refundAmount > paymentAmountТакие правила полезно воспринимать как domain invariants.
Состояние нужно менять одной транзакцией
Допустим webhook подтверждает платёж.
В транзакции:
BEGINможно:
1. сохранить event;
2. обновить payment;
3. перевести order в PAID;
4. записать status history;
5. добавить outbox event.После этого:
COMMITЛибо платёжная бизнес-операция зафиксирована целиком.
Либо не зафиксирована вообще.
Нельзя отправлять email посередине database transaction
Например:
BEGIN
↓
Order = PAID
↓
sendEmail()
↓
COMMITSMTP завис.
Транзакция долго держит locks.
Или email отправился, а database commit потом не удался.
Теперь клиент получил:
Заказ оплачен.
А БД считает:
AWAITING_PAYMENTПоэтому внешние side effects лучше выводить за границу основной transaction через очередь/outbox.
Webhook может прийти не в том порядке, в котором вы его ожидали
Distributed delivery не стоит воспринимать как идеально линейный журнал.
Например, система уже знает:
SUCCEEDEDа затем получает запоздалое событие, относящееся к более раннему состоянию:
PENDINGНельзя автоматически сделать:
SUCCEEDED → PENDINGтолько потому, что такой webhook пришёл последним.
Поэтому нельзя использовать «последний webhook побеждает»
Важнее определить:
каково authoritative
текущее состояние объекта?Один из вариантов — после значимого webhook запросить актуальный payment object у провайдера.
Другой — применять только разрешённые переходы state machine, учитывая специфику API.
Например:
PENDING → SUCCEEDEDразрешено.
Но:
SUCCEEDED → PENDINGнет.
Время получения события тоже не всегда равно времени бизнес-операции
Webhook пришёл:
12:05Это не обязательно означает, что платёж был подтверждён именно:
12:05Если провайдер передаёт authoritative timestamp операции, полезно различать:
providerOccurredAtи:
receivedAtЭто помогает при расследовании инцидентов и reconciliation.
Payment event log чрезвычайно полезен для поддержки
Когда клиент пишет:
Деньги списались, а заказ не появился.
Без истории начинается гадание.
С нормальным журналом можно увидеть:
14:01:03
checkout created
14:01:04
provider payment created
14:02:11
payment succeeded at provider
14:02:12
webhook received
14:02:12
order → PAID
14:02:13
ORDER_PAID outbox created
14:02:14
subscription activatedИли найти конкретную точку сбоя.
Но технический журнал не должен хранить данные карты
Нельзя бездумно логировать полный request/response платёжного API.
Там могут находиться чувствительные данные.
Практичнее сохранять необходимые идентификаторы и техническую информацию:
paymentId
orderId
provider
status
amount
currency
eventId
timestamps
errorCodeИ избегать хранения того, что приложению вообще не требуется.
Чем меньше ваше приложение работает с карточными данными напрямую, тем лучше
Для обычного веб-продукта разумно использовать предоставляемый платёжным провайдером безопасный checkout/tokenization flow, вместо создания собственного интерфейса обработки реквизитов карты без реальной необходимости.
Это уменьшает количество чувствительных данных внутри собственного контура.
Точные требования безопасности и соответствия стандартам при этом зависят от выбранного провайдера и способа интеграции.
Webhook endpoint не должен зависеть от пользовательской сессии
Платёжный провайдер не является браузером клиента.
Поэтому webhook не должен требовать:
user cookie
CSRF token
browser sessionОн имеет отдельную machine-to-machine модель аутентификации и проверки подлинности.
Это отдельный интеграционный endpoint.
Webhook нельзя привязывать к текущему пользователю
Плохая логика:
req.user.orderВ webhook вообще нет обычного req.user.
Связь строится через стабильные серверные идентификаторы:
providerPaymentId
↓
PaymentAttempt
↓
Order
↓
CustomerMetadata полезна, но не должна быть единственной защитой
При создании платежа часто можно передать:
orderId = 1842как metadata.
Это удобно.
Но backend всё равно должен проверить связь:
providerPaymentId
↔
local PaymentAttempt
↔
Orderа не просто доверять произвольному полю webhook.
Ещё один важный слой — сверка платежей
Даже с идеальными webhook полезно периодически сравнивать собственную систему с платёжным провайдером.
Например:
Local:
PENDING
Provider:
SUCCEEDEDили:
Local:
PAID
Provider:
CANCELEDесли такая комбинация допустима в конкретной интеграции.
Reconciliation job помогает обнаруживать такие расхождения.
Финансовые системы должны проектироваться с возможностью расследования
Для каждого заказа полезно быстро ответить:
Какой платёж относится к заказу?
Какую сумму ожидали?
Какую получили?
Когда?
Какой webhook подтвердил?
Обрабатывался ли он повторно?
Был ли refund?
Кто его инициировал?
Какие бизнес-действия произошли после оплаты?Если ответ требует просмотра пяти несвязанных логов на VPS, платёжный контур трудно поддерживать.
Хорошая административная панель тоже помогает
Например, администратор открывает заказ:
Заказ #1842и видит:
Стоимость:
5 900 ₽
Оплата:
Успешно
Provider Payment ID:
p_839...
Создан:
14:01
Оплачен:
14:02
Возврат:
нети техническую историю:
Payment created
Webhook received
Payment confirmed
Order activatedЭто значительно полезнее одной зелёной надписи:
ОплаченоНо администратор не должен вручную ставить «Оплачено»
Если финансовый статус определяется платёжным провайдером, кнопка:
[Отметить оплаченным]опасна.
Она создаёт расхождение:
Business system:
PAID
Provider:
нет платежаЕсли ручная корректировка действительно нужна, это должна быть отдельная контролируемая бизнес-операция:
MANUAL_PAYMENT_CONFIRMEDс:
правами;
причиной;
audit;
источником платежа.Не подмена webhook.
Для банковского перевода может быть другая state machine
Например:
AWAITING_MANUAL_CONFIRMATIONАдминистратор проверяет поступление и фиксирует:
ManualPaymentТак система различает:
CardProviderPaymentи:
BankTransferвместо искусственного притворства, будто все способы оплаты имеют одинаковый технический lifecycle.
Платёжный провайдер не должен знать внутреннюю бизнес-логику
Провайдер говорит:
payment succeededОн не должен управлять:
ролью пользователя;
статусом проекта;
складом;
подпиской;
выдачей лицензии.Это задача вашего backend.
Хорошая граница:
Payment provider
→ финансовый факт
Application
→ бизнес-следствияСобытие оплаты лучше преобразовать во внутреннее событие
Например:
provider webhook:
payment.succeededпосле проверки превращается во внутреннее:
PAYMENT_CONFIRMEDили:
ORDER_PAIDОстальные части приложения не обязаны знать специфику конкретного платёжного API.
Это упрощает смену провайдера
Сегодня:
Provider AЗавтра:
Provider BЕсли вся бизнес-логика построена вокруг:
provider_a.payment_succeededмиграция будет болезненной.
Если существует внутренний слой:
PaymentGatewayс операциями:
createPayment()
getPayment()
refundPayment()и внутренними состояниями, сменить adapter значительно проще.
Конкретный провайдер — инфраструктурная деталь
Архитектура приложения может выглядеть так:
Checkout
↓
PaymentService
↓
PaymentGateway interface
↓
Provider Adapter
↓
External APIWebhook проходит обратный путь:
Provider
↓
Webhook Adapter
↓
Verified payment event
↓
PaymentService
↓
DomainТак внешний API не проникает во все слои продукта.
Основной сценарий целиком
Теперь соберём процесс.
1. Пользователь оформляет заказ
Backend фиксирует:
товары;
количество;
стоимость;
валюту;
условия.Заказ становится:
AWAITING_PAYMENT2. Пользователь нажимает «Оплатить»
Frontend инициирует:
checkout operationс idempotency key.
3. Backend проверяет заказ
существует?
принадлежит пользователю?
не оплачен?
не истёк?
сумма корректна?4. Создаётся локальная PaymentAttempt
CREATED5. Backend создаёт платёж у провайдера
С собственным:
Idempotency-Key6. Сохраняется provider payment ID
PaymentAttempt
→ PENDING7. Пользователь проходит внешний checkout
Браузер здесь отвечает только за взаимодействие пользователя с оплатой.
8. Провайдер подтверждает состояние
Через:
webhookили последующую серверную сверку.
9. Backend проверяет webhook
подлинность;
paymentId;
amount;
currency;
текущее состояние.10. Event дедуплицируется
Повтор не создаёт повторного результата.
11. В database transaction
Payment → SUCCEEDED
Order → PAID
Status history
Outbox ORDER_PAID12. Backend отвечает webhook
Тяжёлые операции не удерживают HTTP request без необходимости.
13. Worker читает outbox
Идемпотентно:
выдаёт доступ;
создаёт подписку;
запускает fulfillment;
отправляет уведомление.14. Пользователь видит реальное состояние заказа
Не состояние redirect.
А данные backend.
Как выглядит архитектура
USER
│
│ «Оплатить»
▼
FRONTEND
│
▼
CHECKOUT API
│
┌───────┴────────┐
│ │
▼ ▼
ORDER PAYMENT ATTEMPT
│
Idempotency-Key
│
▼
PAYMENT PROVIDER
│ │
checkout │ webhook
│ │
▼ ▼
USER WEBHOOK API
│
verify + dedupe
│
▼
DB TRANSACTION
│ │ │
▼ ▼ ▼
Payment Order Outbox
│
▼
WORKER
│
┌──────────────┼──────────────┐
▼ ▼ ▼
subscription email fulfillmentГлавное в этой схеме — отсутствие единственной хрупкой точки:
пользователь вернулся
→ значит деньги полученыКакие сценарии обязательно тестировать
Happy path:
нажал
→ оплатил
→ webhook
→ PAID— это самый простой тест.
Намного интереснее следующие.
Двойной клик
2 checkout requests
→ 1 бизнес-операцияTimeout при создании платежа
Повтор не создаёт второй платёж.
Webhook пришёл дважды
Бизнес-эффект выполняется один раз.
Webhook пришёл до browser redirect
Success page корректно показывает PAID.
Browser redirect пришёл раньше webhook
Показываем PENDING, затем состояние обновляется.
Пользователь закрыл вкладку
Оплата всё равно фиксируется.
SMTP недоступен
Заказ остаётся PAID, письмо повторяется отдельно.
Worker упал
Outbox не позволяет потерять business event.
Поздний webhook
Нельзя откатить более новое состояние старым событием.
Webhook не пришёл
Reconciliation находит фактическое состояние.
Два параллельных refund
Суммарный возврат не превышает допустимую сумму.
И особенно полезен тест «деньги получили, response потеряли»
Это один из ключевых сценариев распределённых систем.
Нужно искусственно смоделировать:
Provider:
payment created
Network:
response lostа затем выполнить retry.
Ожидаемый результат:
один provider paymentа не два.
Такой тест значительно ценнее десятка проверок обычной success page.
Наблюдаемость платежей — тоже часть архитектуры
Полезные метрики:
created payments;
successful payments;
failed payments;
pending age;
webhook errors;
duplicate webhook;
reconciliation mismatch;
refund errors;
provider latency.Особенно полезно отслеживать:
PENDING старше N минутЕсли таких операций внезапно становится много, проблема может находиться в webhook или провайдере.
Alert лучше получать до жалобы клиента
Плохой monitoring:
Клиент написал, что деньги списались, а подписка не появилась.
Хороший:
ALERT
17 payments are
SUCCEEDED at provider
but local entitlement
has not been activated.Именно reconciliation и observability позволяют обнаружить это самостоятельно.
Что должен увидеть пользователь
Платёжная архитектура может быть сложной.
Интерфейс — нет.
Для пользователя достаточно понятных состояний:
Ожидаем оплатуПроверяем оплатуОплата прошлаОплатить не удалосьВозврат оформляетсяДеньги возвращеныНе нужно показывать:
WEBHOOK_RECONCILIATION_PENDINGтолько потому, что так называется внутреннее состояние.
А администратору нужна более подробная картина
Например:
Заказ:
#1842
Стоимость:
5 900 ₽
Payment:
p_839...
Provider:
...
Состояние:
SUCCEEDED
Webhook:
получен 14:02:12
Retries:
1 duplicate ignored
Fulfillment:
COMPLETEDЭто резко сокращает время поддержки платёжных инцидентов.
Где обычно ошибаются
Первая ошибка:
считать success redirect подтверждением денег.
Вторая:
не использовать idempotency при создании платежа.
Третья:
считать webhook одноразовым событием.
Четвёртая:
выполнять тяжёлые side effects прямо внутри webhook request.
Пятая:
смешивать payment status, order status и subscription status.
Шестая:
доверять сумме из frontend.
Седьмая:
не иметь reconciliation.
Восьмая:
делать бизнес-эффекты неидемпотентными.
Девятая:
не сохранять достаточную историю для расследования.
И десятая:
проектировать только happy path.
Самая важная мысль — платёж является распределённым процессом
Когда пользователь нажимает кнопку, в операции участвуют как минимум:
Browser
Application Backend
Database
Payment Provider
Webhook delivery
Background WorkerУ каждой границы есть сеть.
А где есть сеть, существуют:
timeout;
retry;
duplicate;
delay;
partial failure.Нельзя устранить все такие ситуации.
Зато можно построить систему, в которой они не приводят к неправильному финансовому результату.
Вместо вывода
Кнопка:
[Оплатить]— одна из самых коротких надписей в интерфейсе.
Но за ней находится один из наиболее ответственных процессов веб-продукта.
Надёжная архитектура не предполагает:
запрос пришёл один раз;
webhook пришёл один раз;
ответ никогда не потеряется;
события всегда придут по порядку.Она предполагает обратное.
Запрос может повториться.
Ответ может потеряться.
Webhook может прийти дважды.
Пользователь может исчезнуть после оплаты.
Worker может упасть после database commit.
А приложение всё равно должно прийти к одному корректному бизнес-состоянию.
Поэтому хороший платёжный контур строится вокруг нескольких принципов:
Заказ ≠ платёж.
Redirect ≠ подтверждение оплаты.
Timeout ≠ неуспешная операция.
Webhook может повторяться.
Каждый важный эффект должен быть идемпотентным.
Состояния должны изменяться транзакционно.
Тяжёлые side effects лучше выполнять через очередь/outbox.
Webhook нужен для скорости.
Reconciliation — для надёжности.И, пожалуй, главное:
надёжная платежная система — это не та, где каждый запрос всегда выполняется идеально. Это система, которая остаётся финансово корректной даже тогда, когда запросы, события и внешние сервисы ведут себя далеко не идеально.