Прямой ответ
Платёж проходит набор состояний от создания до завершения. Внутренний набор статусов определяется сервисом, а статусы провайдера отображаются в него по явной таблице.
Минимальный набор внутренних статусов:
| Статус | Значение | Возможные переходы |
|---|---|---|
created |
Платёж создан, ожидает подтверждения | pending, failed, canceled |
pending |
Передан провайдеру, результат неизвестен | succeeded, failed, unknown |
succeeded |
Средства зачислены | refunded, partially_refunded |
failed |
Отклонён провайдером либо банком | — |
canceled |
Отменён до завершения | — |
unknown |
Результат не определён после таймаута | succeeded, failed |
partially_refunded |
Часть средств возвращена | refunded |
refunded |
Средства возвращены полностью | — |
Статус unknown обязателен: таймаут запроса к провайдеру не означает отказ, поскольку операция могла выполниться после разрыва соединения. Перевод в failed при таймауте приводит к повторному созданию платежа и двойному списанию.
Допустимые переходы фиксируются в базе:
CREATE TABLE payment_transitions (
from_status text NOT NULL,
to_status text NOT NULL,
PRIMARY KEY (from_status, to_status)
);
INSERT INTO payment_transitions VALUES
('created','pending'), ('created','failed'), ('created','canceled'),
('pending','succeeded'), ('pending','failed'), ('pending','unknown'),
('unknown','succeeded'), ('unknown','failed'),
('succeeded','partially_refunded'), ('succeeded','refunded'),
('partially_refunded','refunded');
Переход выполняется условным обновлением, поэтому недопустимый переход отклоняется:
UPDATE payments p
SET status = $2, updated_at = now()
FROM payment_transitions t
WHERE p.id = $1 AND p.status = t.from_status AND t.to_status = $2;
Если обновление затронуло ноль строк, переход недопустим либо состояние изменилось параллельно. О модели переходов — в статье про конечные автоматы в бизнес-логике.
Сопоставление статусов провайдера и внутренние статусы
Провайдер использует собственный набор статусов, который не совпадает с внутренним ни по составу, ни по семантике. Сопоставление выполняется явной таблицей.
Три правила сопоставления.
Один статус провайдера — один внутренний статус. Несколько внутренних статусов для одного статуса провайдера создают неоднозначность при обновлении.
Неизвестный статус провайдера не игнорируется. Новый статус, появившийся после интеграции, переводит платёж в unknown и требует разбора, а не пропускается.
Промежуточные статусы не считаются завершением. Статус «обрабатывается» у провайдера соответствует внутреннему pending, а не succeeded.
Практическое следствие: добавление статуса провайдера — изменение интеграции, требующее проверки. Журнал несопоставленных статусов показывает, когда провайдер расширил набор.
Отдельный вопрос — источник состояния. Уведомление провайдера не устанавливает внутренний статус напрямую, а запускает проверку; о приёме уведомлений — в статье про платёжные вебхуки. Источником становится ответ запроса состояния, поскольку уведомление может прийти вне порядка либо быть подделано.
Состояние unknown требует отдельной обработки: периодическая задача уточняет статус запросом к провайдеру и переводит платёж в succeeded либо failed. Длительное пребывание в unknown указывает на сбой уточнения и требует оповещения.
История переходов сохраняется отдельно, поскольку текущий статус не показывает последовательность:
CREATE TABLE payment_status_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payment_id bigint NOT NULL,
from_status text,
to_status text NOT NULL,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
Практика: работа со статусами платежа
1. Определяйте внутренний набор статусов независимо от провайдера. Смена провайдера не должна менять внутреннюю модель состояний.
2. Фиксируйте допустимые переходы в базе. Переход, не описанный в таблице, отклоняется, что исключает недопустимые состояния.
3. Используйте условное обновление при переходе. Параллельные обновления не приводят к недопустимому состоянию, поскольку условие проверяет текущий статус.
4. Выделяйте неопределённое состояние. Таймаут переводит платёж в unknown, а не в failed, что исключает двойное списание.
5. Уточняйте неопределённое состояние по расписанию. Задача опрашивает провайдера и переводит платёж в завершённое состояние.
6. Сохраняйте историю переходов. Текущий статус не показывает последовательность, необходимую при разборе обращений.
7. Журналируйте несопоставленные статусы провайдера. Новый статус указывает на изменение API и требует обновления таблицы сопоставления.
Рекомендации:
- Не смешивайте статус платежа и статус заказа. Заказ может быть отменён при успешном платеже, что требует возврата, а не изменения статуса платежа.
- Различайте отказ и отмену. Отказ означает отклонение банком, отмена — действие пользователя либо сервиса, и обработка различается.
- Проверяйте состояние при возврате пользователя. Возврат на страницу результата не означает завершение платежа; о схеме — в статье про интеграцию шлюза.
- Учитывайте частичный возврат. Возврат части суммы требует отдельного статуса, иначе учёт остатка нарушается.
- Документируйте таблицу сопоставления. Соответствие статусов фиксируется вместе с интеграцией и проверяется при обновлении версии API.
Практический сценарий: платёж отмечен неуспешным при фактическом списании
Контекст: пользователь получил списание, а заказ остался неоплаченным.
- Диагностика. В журнале провайдера платёж в успешном статусе, в базе сервиса —
failed. Переход выполнен обработчиком уведомления после таймаута запроса создания платежа. - Причина. Таймаут запроса создания трактовался как отказ, и платёж переведён в
failed. Фактическое списание выполнилось на стороне провайдера после разрыва соединения, а уведомление о успехе не изменило статус, поскольку переход изfailedвsucceededне описан. - Действия. (а) Ввели статус
unknownдля результата, который не определён после таймаута; (б) добавили задачу уточнения, переводящуюunknownвsucceededлибоfailedпо ответу провайдера; (в) разрешили переход изunknownв оба завершающих состояния и запретили прямой переход изfailedвsucceeded; (г) добавили оповещение о платежах, находящихся вunknownдольше заданного интервала; (д) выполнили сверку платежей вfailedс отчётом провайдера и возврат по расхождениям. - Результат. Таймаут не приводит к неверному статусу, а расхождение обнаруживается сверкой и оповещением.
Вывод: таймаут запроса к провайдеру требует неопределённого состояния, поскольку результат операции неизвестен. Уточнение по расписанию и запрет недопустимых переходов исключают расхождение между фактическим списанием и внутренним статусом.
FAQ
Зачем нужен статус неопределённого состояния? Таймаут не означает отказ, поэтому результат уточняется запросом. Перевод в отказ при таймауте приводит к повторному созданию платежа.
Можно ли использовать статусы провайдера напрямую? Нет. Смена провайдера меняет внутреннюю модель, а семантика статусов различается, поэтому используется таблица сопоставления.
Что делать с неизвестным статусом провайдера? Перевести платёж в неопределённое состояние и зафиксировать статус для разбора. Пропуск неизвестного статуса скрывает изменение API.
Нужна ли история переходов, если есть текущий статус? Да. Текущий статус не показывает последовательность, которая требуется при разборе спорных ситуаций.
Источники: Stripe Payment Intents, PayPal Orders API, PostgreSQL constraints — ссылки в шапке.