Возврат с собственным ключом и остатком суммы
Если успешную оплату нужно вернуть, нельзя просто поменять succeeded на canceled. Деньги сначала были получены, затем возникло новое обратное движение. Поэтому «Пространство» хранит возврат отдельной строкой с суммой, ключом, идентификатором провайдера и состоянием. Это позволяет видеть исходную оплату и каждый частичный возврат.
Урок использует только локальный fixture-адаптер. Он не перечисляет деньги и не обращается к реальным банковским данным. Полные методы RefundAsync и ApplyRefundAsync находятся в BillingService.cs архива; сценарии возврата при подготовке не выполнялись. Предметные сроки и правовые обязанности настоящего продукта здесь не определяются.
Сумма возврата имеет подтверждённый предел
Owner выбирает платеж внутри компании и отправляет положительное количество копеек. Сервер проверяет succeeded и вычисляет доступный остаток: исходная сумма минус подтверждённые возвраты минус уже зарезервированные незавершённые. Последняя часть важна для двух одновременно созданных команд.
Если исходная сумма 99000, подтверждённо возвращено 20000 и ещё 30000 ожидает провайдера, новая команда может зарезервировать не больше 49000. Если считать только успешные возвраты, два параллельных pending по 79000 каждый могли бы пройти проверку. Блокировка платежа и сумма резервов задают согласованный предел.
Ниже — полный фрагмент агрегирования из транзакции возврата. Организация и paymentId уже ограничены контекстом, а платеж прочитан с FOR UPDATE. Состояние canceled не резервирует сумму, pending и succeeded резервируют.
SELECT coalesce(sum(amount_minor),0)
FROM refunds
WHERE organization_id=@organization
AND payment_id=@payment
AND state IN ('pending','succeeded');
Ожидаемый отказ превышения — 409 с кодом refund_exceeds_remaining. Нельзя корректировать сумму вниз автоматически: владелец попросил конкретное значение и должен явно согласиться с другим. Ответ показывает доступный остаток только в пределах доступного ему платежа; чужие суммы не раскрываются.
Ключ возврата отличается от ключа покупки
Команда имеет собственный Idempotency-Key, ограниченный компанией и видом операции. Hash включает paymentId и amountMinor. Повтор с прежним ключом и теми же полями возвращает тот же refundId. Изменение суммы с тем же ключом даёт конфликт, а не редактирует уже отправленное намерение.
Provider key связывается с refundId. Если ответ адаптера потерялся после создания возврата, повтор использует прежний ключ. В fixture-таблице хранятся сумма, исходный provider payment и состояние. Его уникальность предотвращает повторное обратное движение при сетевом retry.
Возврат создаётся локально до вызова адаптера, как платеж. Удерживать блокировку платежа на всё время внешнего запроса нельзя: это задержало бы другие действия и всё равно не дало бы rollback внешнего результата. Резервированная pending-строка сохраняет намерение, а повтор и сверка продолжают его после освобождения транзакции.
Подтверждение обновляет финансовую запись
После авторитетного succeeded-снимка возврата сервис сверяет providerId, payment providerId, сумму и валюту. Затем блокирует локальный refund и исходный платеж, отмечает успешность и пересчитывает подтверждённый итог. Повтор уже succeeded не прибавляет сумму второй раз.
Нельзя увеличивать refunded_minor при каждом webhook без уникальной операции. Три сообщения об одном возврате тогда выглядели бы как три разных возврата. Уникальный refundId и переход pending → succeeded выражают одну операцию. Итог в платежной записи может использоваться для удобного чтения, но его происхождение остаётся в отдельных строках.
Для настоящего провайдера возможны отличающиеся состояния, отказы и условия. Первичный справочник ЮKassa описывает объект Refund и его методы. Наш локальный адаптер подтверждает возврат немедленно, поэтому курс не обещает поддержку всех реальных задержек только на основании этой fixture-ветки.
Если adapter отвечает canceled, резерв освобождается по завершённому отказу. Если ответ неизвестен из-за таймаута, pending-резерв остаётся. Отпустить его сразу и позволить новый полный возврат опасно: первая операция могла уже завершиться на стороне провайдера. Неопределённость требует сверки, а не догадки по отсутствующему HTTP-ответу.
Политика доступа после возврата
В нашем учебном продукте частичный возврат не сокращает период автоматически. Полный возврат конкретного заказа отзывает его entitlement. Это выбранное правило, которое связывает денежный итог с услугой. Оно не является универсальной рекомендацией: реальный продукт может иметь другое согласованное поведение.
Отзыв применяется к grant исходного заказа, а не ко всем правам компании. Если другой период уже оплачен отдельным заказом, его нельзя случайно удалить общим DELETE WHERE organization_id. Ссылка entitlement на orderId делает причину и область отзыва проверяемыми. Подписка может отдельно перейти в canceled, чтобы не планировать новые попытки после полного возврата текущего периода.
Доступ проверяется сервером при каждом запросе. Если вернуть деньги, но только скрыть кнопку экспорта в интерфейсе, старый клиент продолжит вызывать API. Финансовый commit должен изменить сохранённое основание доступа; подробную проверку рассмотрим в заключительном уроке.
Два возврата в ручном сценарии
После оплаты 99000 создайте частичный возврат 20000 с отдельным ключом. Ожидаемо платёж остаётся succeeded, а итог возвратов равен 20000. Повтор той же команды не меняет итог. Попытка вернуть 80000 затем получает отказ, поскольку остаток составляет 79000.
Создайте возврат остатка 79000 с новым ключом. После подтверждения ожидается полный итог 99000 и отзыв grant этого заказа. История по-прежнему содержит успешную оплату и два возврата. Такая форма объясняет движение денег лучше, чем единственный canceled без суммы и происхождения.
Возврат можно продолжить после перезапуска
Если приложение остановилось после создания fixture refund, повтор исходного Idempotency-Key находит ту же локальную pending-строку. Адаптер возвращает тот же provider refund по его отдельному ключу, затем ApplyRefund завершает запись. В архиве такое продолжение выполняется повтором owner-команды; отдельного сетевого webhook реального возврата нет. Для внешнего провайдера добавьте долговечную очередь и чтение refund по его API, сохранив прежнюю проверку суммы и исходного payment. Не объявляйте pending неуспехом только потому, что пользователь больше не держит страницу открытой. Это сохранённое обратное намерение, которое должно получить достоверный итог.
Следующий урок завершает курс проверкой платной функции. Он соединит роль, состояние организации, время и конкретное оплаченное право, чтобы продукт не зависел от текста тарифа в браузере.