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

Адаптер провайдера и повтор создания платежа

Заказ зафиксирован, но деньги ещё не подтверждены. Теперь приложение должно создать попытку оплаты у провайдера и связать её идентификатор с локальным paymentId. Между этими действиями возможны таймауты и перезапуск, поэтому адаптер должен иметь ясный контракт повторов. В этом курсе он работает только с локальными fixtures.

Полная реализация FixturePaymentProvider хранит свои объекты в PostgreSQL отдельно от прикладных платежей. Она имитирует создание, чтение состояния и возврат, но не вызывает платежную сеть. Типы находятся в PaymentProvider.cs архива. Код при подготовке не запускался; пример не проводит никаких реальных платежей.

Приложение передаёт намерение, адаптер возвращает снимок

Интерфейс не должен раскрывать HTTP-заголовки конкретного провайдера каждому обработчику продукта. Сервис передаёт сумму, валюту, сохранённый ключ и ссылку на локальное намерение. Адаптер возвращает свой идентификатор, состояние и необходимые сведения подтверждения. Ниже приведено целое определение интерфейса курса.

public interface IPaymentProvider
{
    Task<ProviderPayment> CreateAsync(PaymentIntent intent, CancellationToken ct);
    Task<ProviderPayment> GetAsync(string providerId, CancellationToken ct);
    Task<ProviderRefund> RefundAsync(RefundIntent intent, CancellationToken ct);
}

Здесь PaymentIntent уже содержит проверенную Money и стабильный provider key. Адаптер не должен заново выбирать тариф либо членство. Иначе повтор одной попытки может получить другую сумму после изменения каталога. Его задача — передать сохранённое намерение и перевести ответ в согласованную внутреннюю модель.

Модель ProviderPayment сохраняет сумму и валюту ответа. Они понадобятся для сверки: один успешный status недостаточен, если он относится к другому объекту или сумме. Внутренний orderId можно передать как метаданные для диагностики, но безопасность не должна зависеть только от необязательной строки metadata.

Provider key живёт вместе с попыткой

При создании локального платежа сервер формирует ключ вида space:{paymentId}. Он сохраняется до вызова. Если ответ адаптера потерялся, повтор использует то же значение. Fixture-таблица имеет UNIQUE на key и возвращает прежний providerId при одинаковом намерении, а несовпадение суммы получает отказ.

В реальной ЮKassa ключ идемпотентности описан в формате взаимодействия API. Данный курс не переносит автоматически условия всех провайдеров на локальный адаптер. У каждого внешнего сервиса есть собственный срок ключа и правила повторов; их нужно учитывать в конкретной реализации интерфейса.

Если после таймаута создать новый ключ, провайдер может законно создать второй платеж. Таймаут означает отсутствие достоверного ответа у клиента, а не доказательство отсутствия объекта у сервера. Поэтому состояние локального платежа остаётся pending либо awaiting_provider, а не автоматически canceled. Сверка позже установит фактический результат.

Потеря связи до отправки и после отправки внешне выглядит одинаково. Именно сохранённый ключ позволяет не различать эти моменты вручную. После перезапуска сервис находит paymentId, повторяет создание с тем же ключом и связывает возвращённый providerId. Журнал полезен для объяснения, но не заменяет такую запись в базе.

Подтверждение пользователя не является успехом

В локальном адаптере создание возвращает pending. Отдельный loopback-маршрут позволяет перевести fixture в succeeded либо canceled. Он предназначен ручному сценарию и отсутствует в настоящем договоре провайдера. Нет формы ввода карты и нет скрытого успешного состояния сразу после POST заказа.

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

Ниже приведён ожидаемый локальный снимок после создания. Сумма совпадает с заказом, а pending не открывает платный экспорт.

{"id":"fixture-payment-1","state":"pending","amountMinor":99000,"currency":"RUB"}

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

Ошибки адаптера имеют разные значения

Неверная сумма или недоступный способ подтверждения являются предметным отказом. Таймаут и временная недоступность являются неопределённостью выполнения. Разбор неизвестного JSON-ответа — ошибка совместимости адаптера. Если все три случая переводить в canceled, продукт будет закрывать операции, которые могли фактически завершиться успешно.

В учебном коде fixture-команды сохраняются независимо от прикладной транзакции. Это имитирует важное свойство внешнего провайдера: rollback заказа не отменяет его запись. Поэтому сначала фиксируется локальное намерение, и только затем создаётся fixture. Случайный общий transaction scope для обеих таблиц сделал бы пример слишком удобным и скрыл бы настоящий промежуток сбоя.

При переносе на тестовую ЮKassa потребуется отдельная конфигурация тестового магазина, обработка аутентификации и реальных ответов. Первичная инструкция тестирования является отправной точкой. Курс не содержит таких реквизитов и не требует перехода от локального адаптера для изучения механизма состояний.

Для ручного наблюдения создайте fixture с одним provider key два раза. Ожидаемо возвращается один providerId. Затем повторите тот же key с другой суммой: ожидается конфликт, а не второй платеж. Это показывает, что идемпотентность подтверждает прежнее намерение, а не позволяет произвольно переписать существующий объект.

Состояние fixture не исчезает с процессом

После перезапуска API локальная таблица провайдера остаётся в базе. Поэтому GetAsync способен вернуть созданный ранее объект, а CreateAsync с прежним key — найти его. Это полезное свойство учебного адаптера, но оно требует той же локальной базы и сохранённой схемы. Удаление fixture-таблиц между шагами разрушает сценарий сверки, даже если прикладные платежи сохранены. README отделяет подготовку новой базы от продолжения существующей: повторный seed не является способом перезапустить приложение. Использование отдельной транзакции провайдера намеренно показывает, что приложение не управляет чужим commit. Локальная близость таблиц не должна скрывать эту архитектурную границу при чтении кода.

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

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