Перейти к содержимому
ZEVSLAB

Как проектировать надёжные рекуррентные списания

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

Обобщённый сценарийПодписочный сервис

Контекст

Рассмотрим типовой подписочный сервис: пользователь привязывает карту, дальше система списывает оплату автоматически с заданной периодичностью. Продукт рос органически — приём платежей добавлялся по мере надобности, без отдельного проектирования денежного контура.

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

Проблема

Исходный симптом можно сформулировать так: «мы теряем деньги на продлениях, но не понимаем где». За ним могут скрываться три разные проблемы, которые до разбора выглядят как одна.

Отказы неразличимы. Система записывает неуспешное списание одним статусом. Отказ из-за недостатка средств на карте и отказ из-за таймаута при обращении к провайдеру попадают в одну корзину. Понять, какая часть потерь в принципе поддаётся исправлению, невозможно — а без этого нельзя оценить, окупится ли работа.

Повторные вебхуки создают дубликаты. Провайдер, не получив подтверждения обработки, доставляет уведомление повторно — это нормальное поведение, описанное в его документации. Обработчик без идемпотентности не знает, видел ли он это событие раньше. При медленном ответе приложения одно уведомление может обработаться дважды и породить двойное начисление.

Ретраев фактически нет. Планировщик пытается списать один раз в назначенный день. Если попытка не удалась, следующая происходит через полный период подписки. Карта, на которой средства появились через сутки, до следующего цикла не проверяется.

Ограничения

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

Диагностика

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

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

Есть в системеНет в системе
Есть у провайдера

Норма — записи совпадают по идентификатору и сумме. Сюда попадает основная масса операций.

Потеря — платёж прошёл, но в системе не отразился. Клиент заплатил и не получил доступ — самая дорогая из групп.

Нет у провайдера

Расхождение — система считает платёж успешным без подтверждения. Обычно — обработанный дважды вебхук или незавершённая попытка.

Норма — операции не было ни с одной стороны, разбирать нечего.

Таблица 1Раскладка расхождений при сверке с реестром провайдера. Диагональ — норма: операция либо есть с двух сторон, либо отсутствует с двух. Остальные две клетки и есть потерянные деньги, причём в разные стороны: одна означает неучтённое поступление, другая — запись о платеже, которого не было.

Отдельно от этой раскладки существуют расхождения по суммам: операция есть с двух сторон, но суммы не совпадают. Они попадают в первую клетку по признакам и выявляются вторым проходом сверки — по сумме, а не по факту наличия.

Именно эта таблица, а не чтение кода, даёт первую достоверную картину.

Параллельно логи обработки вебхуков группируем по идентификатору события. Так повторные обработки одного события можно подтвердить количественно.

Предлагаемая архитектура

Явная машина состояний. Платёж и подписка получают перечень допустимых состояний и разрешённых переходов. Переход, не описанный в модели, вызывает ошибку и попадает в журнал вместо того, чтобы молча испортить данные. Каждый переход записывается с отметкой времени, причиной и источником — внешнее событие, действие оператора или планировщик.

Идемпотентность на уровне события. Уникальный идентификатор события провайдера сохраняется с ограничением уникальности в базе. Повторная доставка приводит к попытке вставки дубликата, которая отсекается базой данных, а не проверкой в коде. Это принципиально: проверка если не существует, то вставить не защищает от одновременной обработки двух копий события в разных процессах, а ограничение уникальности защищает.

Классификация отказов. Коды провайдера разделяются на две категории: отказы, при которых повтор осмыслен, и окончательные. Недостаток средств — повтор осмыслен. Карта заблокирована эмитентом — нет. От категории зависит дальнейшее поведение.

Ретраи по расписанию. Для повторяемых отказов — серия попыток с возрастающими интервалами и ограничением общего числа. После исчерпания попыток подписка переходит в состояние, требующее действия пользователя, и он получает уведомление.

Ежедневная сверка. Задача сопоставляет внутренние записи с реестром провайдера за предыдущий день. Расхождения формируют отчёт. Это тот же процесс, который на этапе диагностики выполняется вручную, — переведённый в регулярный.

Реализация

Реализацию ведём поэтапно: каждый этап выкатывается отдельно и может быть откачен.

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

Затем внедряется идемпотентность вебхуков как отдельное изменение, минимальное по объёму и максимальное по эффекту.

Затем — машина состояний, самый крупный этап. Существующие записи, включая записи в неописанных состояниях, предварительно приводятся к корректным значениям отдельной миграцией с сохранением исходных данных в резервном поле.

Ретраи и сверка добавляются последними, когда состояния уже достоверны.

Каждую ветвь логики ретраев и идемпотентности покрываем тестами. Это тот код, где ошибка не проявляется на глаз и обнаруживается через недели по расхождению в отчётности.

Надёжность и нештатные ситуации

Отдельно проектируем ситуации, которые в спокойном режиме не встречаются:

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

Безопасность и работа с данными

Реквизиты карт в системе не хранятся и через неё не проходят — используется токен провайдера. Логи очищаются от чувствительных полей до записи. Доступ к отчёту сверки ограничен ролями, поскольку он содержит финансовую информацию в агрегированном виде.

Ключи провайдера хранятся в переменных окружения, в репозиторий не попадают и различаются для тестовой и боевой среды.

Решения

Какие решения заложены и какие альтернативы отвергнуты

У каждого решения была более простая альтернатива. Здесь — почему она не подходит.

  1. 01

    Явная машина состояний платежа и подписки

    Почему так

    Переход, не описанный в модели, вызывает ошибку и попадает в журнал. Дефект становится видимым событием в момент возникновения.

    От чего отказались

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

  2. 02

    Ограничение уникальности в базе на идентификаторе события

    Почему так

    База не даёт записать одно событие дважды даже тогда, когда две копии обрабатываются одновременно в разных процессах.

    От чего отказались

    От проверки «если записи нет, то вставить» в коде: она выглядит достаточной и перестаёт работать ровно в тот момент, когда копий события оказывается две.

  3. 03

    Разделение отказов на повторяемые и окончательные

    Почему так

    Видно, какая часть потерь в принципе поддаётся исправлению. Без этого нельзя оценить, окупится ли работа.

    От чего отказались

    От единой корзины «не прошло», в которую попадали и нехватка средств на карте, и таймаут при обращении к сервису.

  4. 04

    Ежедневная сверка вместо разбора по обращению

    Почему так

    Расхождение обнаруживается на следующий день и становится событием системы, а не находкой человека.

    От чего отказались

    От ручной выгрузки по запросу: она показывает картину только тогда, когда кто-то уже заметил проблему.

Итог

Ожидаемый эффект

Для архитектурного сценария это ожидаемое поведение, а не отчёт о достигнутых показателях. Критерии успеха определяются и проверяются на данных конкретного проекта.

  • Технические отказы отделяются от банковских — становится видно, какая часть потерь поддаётся исправлению
  • Повторная доставка вебхука не создаёт дубликаты начислений и заказов
  • Неуспешное списание повторяется по заданному расписанию, а не остаётся без обработки
  • Расхождение с реестром провайдера обнаруживается на следующий день, а не в конце месяца
  • Поддержка разбирает конкретный платёж по журналу переходов, не обращаясь к разработчику
  • Подключение резервного провайдера не требует переписывать бизнес-логику биллинга

Похожий процесс есть в вашей системе?

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