Перейти к содержанию

Переходы состояния платежа

Провайдер вернул pending, затем может сообщить succeeded или canceled. Теперь продукт должен перевести этот снимок в собственное состояние. Простое присваивание строки status из любого входящего JSON опасно: уведомления могут повторяться, задерживаться и относиться к другому объекту. Доступ открывается только после проверенного успешного результата.

В учебном «Пространстве» используется одностадийная локальная модель: pending переходит в succeeded либо canceled. Возврат является отдельным объектом, а не обратным переходом succeeded в canceled. Полный код ApplyPaymentAsync находится в BillingService.cs архива. Эти переходы не выполнялись при подготовке; ниже описан ожидаемый эффект.

Состояние относится к одной попытке

Заказ и платеж имеют разные статусы. Pending-платеж относится к открытому заказу. Succeeded-платеж подтверждает оплату и переводит заказ в paid. Canceled-платеж завершает конкретную попытку без оплаты. Он не доказывает, что компания никогда не купит этот период другой командой.

Текущее состояние Снимок провайдера Результат применения
pending pending Ожидание сохраняется
pending succeeded Оплата и право фиксируются
pending canceled Попытка закрывается
succeeded succeeded Повтор не меняет период
succeeded pending Старый снимок не откатывает успех
canceled canceled Повтор завершённого отказа

В настоящей ЮKassa существуют дополнительные состояния и сценарии, например ожидание подтверждения списания в двухстадийной оплате. Они описаны в процессе платежа. Наш адаптер не заявляет их поддержку. Нельзя заменить его real-реализацией и проигнорировать неизвестное состояние только потому, что таблица учебного курса короче.

Предельные состояния fixture монотонны: после succeeded его нельзя вручную перевести в canceled. Если реальный провайдер допускает иной сценарий, перевод надо определить отдельно. Refund уже не означает, что успешной оплаты никогда не было; история должна сохранить и получение денег, и последующий возврат.

Перед применением сверяются идентификатор и сумма

Сервис находит локальный платеж, проверяет связанный providerId, сумму и валюту снимка. Успешный чужой объект не должен открыть доступ. Метаданные провайдера полезны для поиска, но цена сверяется с сохранённым заказом. Текущий каталог тарифов здесь не читается.

Ниже — законченный фрагмент проверки из ApplyPaymentAsync. payment получен под блокировкой строки, snapshot пришёл через адаптер чтения. При несовпадении возвращается предметная ошибка сверки, а состояние не изменяется.

if (snapshot.Id != payment.ProviderId ||
    snapshot.AmountMinor != payment.AmountMinor ||
    snapshot.Currency != payment.Currency)
    throw new BillingException("provider_snapshot_mismatch");

Не исправляйте несовпадение автоматическим обновлением цены заказа «чтобы она совпала с провайдером». Заказ фиксирует принятую покупку. Несовпадение может означать ошибку маршрутизации, неправильный адаптер либо повреждённые данные. Его нужно расследовать, сохранив исходные значения, а не подогнать документы под успешный статус.

Проверка providerId также защищает от позднего снимка старой попытки, если продукт позднее разрешит новую. В нашем простом контракте заказ создаёт одну попытку, но типы уже сохраняют отдельные идентификаторы. Это позволяет расширить модель без замены заказа универсальной строкой «оплачено».

Блокировка делает повтор безвредным

При применении снимка сервис открывает системный сеанс нужной компании и читает платеж с FOR UPDATE. Два обработчика не могут одновременно увидеть pending и оба выдать период. Первый изменит состояние и сохранит право, второй прочитает уже succeeded и завершится без повторного эффекта.

В одной транзакции обновляются платеж, заказ, подписка, entitlement и аудит. Если выдача права не сохранилась, успешный локальный статус также откатывается. Следующий повтор заново применит тот же подтверждённый снимок. Такой порядок не теряет доступ между «оплата записана» и «право забыто».

Запись entitlement имеет уникальную связь с оплачиваемым заказом. Это дополнительная защита от повторной выдачи. Если увеличивать through_until просто на месяц при каждом succeeded-уведомлении, три одинаковых уведомления дали бы три месяца за один платеж. Наше правило сохраняет границы периода из заказа, а не считает число сообщений.

Чтение подтверждённого состояния не требует активного пользовательского членства. Позднее уведомление может прийти после закрытия компании. Финансовая запись всё равно должна отразить фактический результат, но не должна автоматически возобновить deleted-компанию. Жизненный цикл и оплаченный период остаются разными механизмами.

Ответ продукта отражает его собственную запись

После создания платежа клиент опрашивает свой order endpoint. Pending отображается как ожидание подтверждения. Succeeded с paid-заказом означает, что локальная транзакция применила результат. Прямой текст от return URL или присланный браузером status не используется для изменения базы.

Если провайдер уже succeeded, а продукт ещё pending, интерфейс не должен обещать окончательный отказ. Это промежуток доставки либо сверки, который разберём в следующих уроках. Пользователю можно предложить обновить состояние, а сервису — выполнить чтение провайдера. Нельзя просить оформить новый платеж только из-за задержанного уведомления.

Для ручного наблюдения создайте pending fixture, переведите его в succeeded и примените снимок два раза. Ожидается один paid-заказ, одно право и неизменная граница периода после второго применения. Затем рассмотрите старый pending-снимок: он не должен откатить успешную запись или удалить право.

Отдельно передайте snapshot с другой валютой либо суммой. Ожидается отказ сверки и прежнее локальное состояние. Такой случай показывает, что успех — не одно слово в JSON, а соответствие результата сохранённому намерению. Состояние ошибки сверки полезно отличать от обычного canceled, чтобы оператор не искал несуществующий отказ банка.

Старый период не меняет новый

Успешный снимок старого заказа может прийти после оплаты следующего. Сервис сохраняет финансовый результат, но обновляет текущие границы подписки только если они не движутся назад. Entitlement старого заказа остаётся связанным со своим интервалом и к настоящему моменту может уже истечь. Этот случай отличается от обычного повторного сообщения: orderId другой, но функция всё равно не должна внезапно получить прошлый конец периода. Аналогично отказ старого renewal не переводит уже восстановленную новую подписку в past_due.

Следующий урок подключает входящее уведомление как сигнал. Оно найдёт платеж и инициирует проверенное чтение адаптера, сохранив только что определённые правила переходов и повторов.

Оглавление серии