Архитектура и данные

Что происходит после кнопки «Оплатить»: как спроектировать платежи без двойных операций и потерянных заказов

Со стороны пользователя онлайн-оплата выглядит почти мгновенной.

Он нажимает:

«Оплатить»

переходит на страницу банка или платёжного сервиса, подтверждает операцию и через несколько секунд видит:

Оплата прошла успешно.

Кажется, что техническая реализация должна выглядеть примерно так же просто:

Нажали «Оплатить»
        ↓
Получили деньги
        ↓
Заказ оплачен

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

Пользователь способен нажать кнопку дважды.

Сетевой запрос может завершиться 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:
PENDING

Frontend получает безопасные данные, необходимые для продолжения.

После этого пользователь подтверждает оплату у платёжного провайдера.


Самая опасная ошибка — считать success URL подтверждением оплаты

После оплаты провайдер может вернуть браузер:

https://shop.example/payment/success

Очень легко написать:

onSuccessPage(() => {
  order.status = 'PAID';
});

Так делать нельзя.

Сам факт того, что пользователь открыл:

/payment/success

не является надёжным доказательством движения денег.

URL можно открыть вручную.

Параметры браузера можно изменить.

Пользователь вообще может не вернуться после успешной оплаты.


Redirect отвечает за UX, а не за финансовую истину

Success page должна говорить примерно:

Проверяем статус оплаты…

А затем запросить backend:

GET /api/orders/1842

Backend уже знает 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 #1842

Webhook должен считаться недоверенным входным запросом

Нельзя писать:

if (req.body.event === 'paid') {
    markOrderPaid();
}

только потому, что endpoint называется:

/webhook

Он доступен через интернет.

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

Это может быть:

криптографическая подпись;
проверка источника;
повторный authenticated запрос
к API провайдера;

или комбинация методов.

Главное правило:

нельзя позволять произвольному HTTP-запросу объявлять заказ оплаченным.


Почему полезна дополнительная проверка состояния у провайдера

Представим webhook сообщает:

payment.succeeded

Backend может дополнительно запросить:

GET provider/payment/p_839

через серверную аутентификацию и получить текущее authoritative состояние.

После этого проверить:

paymentId
amount
currency
status
merchant/account

И только затем применять изменение.

Это особенно полезно, когда такой способ проверки предусмотрен интеграцией конкретного провайдера.


Сумма webhook тоже должна совпадать

Даже настоящий платёж нельзя автоматически связать с заказом только по факту:

payment succeeded

Нужно убедиться:

Order:
5900 RUB
Payment:
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 reconciliation

Webhook 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 payment

Provider выполнил операцию.

Но ответ потерялся.

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()
↓
COMMIT

SMTP завис.

Транзакция долго держит 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
↓
Customer

Metadata полезна, но не должна быть единственной защитой

При создании платежа часто можно передать:

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 API

Webhook проходит обратный путь:

Provider
   ↓
Webhook Adapter
   ↓
Verified payment event
   ↓
PaymentService
   ↓
Domain

Так внешний API не проникает во все слои продукта.


Основной сценарий целиком

Теперь соберём процесс.

1. Пользователь оформляет заказ

Backend фиксирует:

товары;
количество;
стоимость;
валюту;
условия.

Заказ становится:

AWAITING_PAYMENT

2. Пользователь нажимает «Оплатить»

Frontend инициирует:

checkout operation

с idempotency key.


3. Backend проверяет заказ

существует?
принадлежит пользователю?
не оплачен?
не истёк?
сумма корректна?

4. Создаётся локальная PaymentAttempt

CREATED

5. Backend создаёт платёж у провайдера

С собственным:

Idempotency-Key

6. Сохраняется provider payment ID

PaymentAttempt
→ PENDING

7. Пользователь проходит внешний checkout

Браузер здесь отвечает только за взаимодействие пользователя с оплатой.


8. Провайдер подтверждает состояние

Через:

webhook

или последующую серверную сверку.


9. Backend проверяет webhook

подлинность;
paymentId;
amount;
currency;
текущее состояние.

10. Event дедуплицируется

Повтор не создаёт повторного результата.


11. В database transaction

Payment → SUCCEEDED
Order → PAID
Status history
Outbox ORDER_PAID

12. 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 — для надёжности.

И, пожалуй, главное:

надёжная платежная система — это не та, где каждый запрос всегда выполняется идеально. Это система, которая остаётся финансово корректной даже тогда, когда запросы, события и внешние сервисы ведут себя далеко не идеально.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →