Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Состояния платежа: жизненный цикл статусов

Состояния платежа: статусы, переходы и неопределённые состояния

Жизненный цикл платежа: набор внутренних статусов, допустимые переходы, неопределённое состояние при таймауте и сопоставление со статусами провайдера.

Прямой ответ

Платёж проходит набор состояний от создания до завершения. Внутренний набор статусов определяется сервисом, а статусы провайдера отображаются в него по явной таблице.

Минимальный набор внутренних статусов:

Статус Значение Возможные переходы
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 и требует обновления таблицы сопоставления.

Рекомендации:

  1. Не смешивайте статус платежа и статус заказа. Заказ может быть отменён при успешном платеже, что требует возврата, а не изменения статуса платежа.
  2. Различайте отказ и отмену. Отказ означает отклонение банком, отмена — действие пользователя либо сервиса, и обработка различается.
  3. Проверяйте состояние при возврате пользователя. Возврат на страницу результата не означает завершение платежа; о схеме — в статье про интеграцию шлюза.
  4. Учитывайте частичный возврат. Возврат части суммы требует отдельного статуса, иначе учёт остатка нарушается.
  5. Документируйте таблицу сопоставления. Соответствие статусов фиксируется вместе с интеграцией и проверяется при обновлении версии API.

Практический сценарий: платёж отмечен неуспешным при фактическом списании

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

  1. Диагностика. В журнале провайдера платёж в успешном статусе, в базе сервиса — failed. Переход выполнен обработчиком уведомления после таймаута запроса создания платежа.
  2. Причина. Таймаут запроса создания трактовался как отказ, и платёж переведён в failed. Фактическое списание выполнилось на стороне провайдера после разрыва соединения, а уведомление о успехе не изменило статус, поскольку переход из failed в succeeded не описан.
  3. Действия. (а) Ввели статус unknown для результата, который не определён после таймаута; (б) добавили задачу уточнения, переводящую unknown в succeeded либо failed по ответу провайдера; (в) разрешили переход из unknown в оба завершающих состояния и запретили прямой переход из failed в succeeded; (г) добавили оповещение о платежах, находящихся в unknown дольше заданного интервала; (д) выполнили сверку платежей в failed с отчётом провайдера и возврат по расхождениям.
  4. Результат. Таймаут не приводит к неверному статусу, а расхождение обнаруживается сверкой и оповещением.

Вывод: таймаут запроса к провайдеру требует неопределённого состояния, поскольку результат операции неизвестен. Уточнение по расписанию и запрет недопустимых переходов исключают расхождение между фактическим списанием и внутренним статусом.

FAQ

Зачем нужен статус неопределённого состояния? Таймаут не означает отказ, поэтому результат уточняется запросом. Перевод в отказ при таймауте приводит к повторному созданию платежа.

Можно ли использовать статусы провайдера напрямую? Нет. Смена провайдера меняет внутреннюю модель, а семантика статусов различается, поэтому используется таблица сопоставления.

Что делать с неизвестным статусом провайдера? Перевести платёж в неопределённое состояние и зафиксировать статус для разбора. Пропуск неизвестного статуса скрывает изменение API.

Нужна ли история переходов, если есть текущий статус? Да. Текущий статус не показывает последовательность, которая требуется при разборе спорных ситуаций.

Источники: Stripe Payment Intents, PayPal Orders API, PostgreSQL constraints — ссылки в шапке.