Сколько раз пытаться списать деньги за подписку? История одной, казалось бы, простой задачи

Сколько раз пытаться списать деньги за подписку? История одной, казалось бы, простой задачи

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

Задача звучала почти неприлично просто: «если автосписание не прошло, попробуй ещё раз через какое-то время». Казалось бы, одна строчка кода. А внутри — комментарий-заглушка: «ретрай через 1, 2, 4, 8… X, где X вычислить исходя из длины периода подписки». И вот тут началось самое интересное.

Первая ловушка: «просто сделай экспоненциальный backoff»

Первая мысль, которая приходит в голову любому, кто писал retry-логику для внешних API — экспоненциальный backoff. Час, два, четыре, восемь часов между попытками. Работает для HTTP-запросов, почему не должно работать для платежей?

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

  • Технический сбой — таймаут на стороне эквайринга, кратковременная недоступность платёжного шлюза. Решается сам за минуты, максимум за пару часов.
  • Финансовая причина — на счету просто нет денег. А это, на секундочку, самая частая причина неудачных автосписаний подписок вообще. И решается она не за часы, а к конкретным датам — зарплате, авансу, очередному пополнению счёта.

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

Вторая ловушка: диапазон подписок — от часа до трёх лет

Дальше выяснилась деталь, которая на корню ломает любое решение с «магическими константами». В системе минимальная подписка может длиться 1 час, а максимальная — 3 года. Формулировка «повторить через 3 дня» звучит разумно для месячной подписки и откровенно абсурдно для часовой: пользователь давно забыл, что вообще что-то покупал, а система всё ещё пытается с него списать.

Значит, единственная работающая идея — это не абсолютные величины, а пропорция от длины периода подписки. Условно: окно всех повторных попыток = какая-то доля от периода. Чем длиннее подписка, тем больше смысла побороться за клиента и подождать; чем короче — тем меньше.

Но и тут есть подвох. Чистая пропорция даёт вырожденные случаи на краях диапазона:

  • Для часовой подписки 20% от периода — это 6 минут на все попытки. Слишком мало: реальному человеку нужно хотя бы сутки, чтобы среагировать и пополнить счёт.
  • Для трёхлетней подписки 20% от периода — это больше полугода. А удерживать «зависшую» неоплаченную подписку такой срок бизнесу попросту невыгодно.

Решение: пропорция, зажатая между двумя жёсткими границами

В итоге логика сложилась в три содержательных бизнес-величины вместо одной:

  1. Floor (нижняя граница) — минимальное окно на все попытки, даже для самой короткой подписки. У нас это сутки: даже для часовой подписки компания готова ждать оплату целый день.
  2. Cap (верхняя граница) — жёсткий потолок, дольше которого бизнес не готов ждать оплату ни при каких условиях. У нас это 3 недели.
  3. Proportional fraction — доля от длины периода (у нас 20%), которая работает в «средней зоне», когда пропорция не упирается ни в пол, ни в потолок.

Такая формула даёт ровно то поведение, которое нужно:

Период подписки Окно ретраев
1 час — 3 дня упирается в floor → 1 сутки
неделя — ~3 месяца растёт пропорционально периоду
полгода и больше упирается в cap → 21 день

Никаких развилок «если период меньше месяца — то так, а если больше — то эдак» — одна формула естественно ведёт себя на всём диапазоне от часа до трёх лет.

Третья деталь: первая попытка — особенная

Когда я уже было решил, что все N попыток нужно просто разложить экспоненциально внутри окна, всплыл ещё один нюанс. Первая повторная попытка вообще не должна зависеть ни от какой пропорции — это чисто техническая проверка «а вдруг был кратковременный сбой сети». Она должна происходить быстро и одинаково для любой подписки: что для часовой, что для трёхлетней — через 5–10 минут.

Поэтому расписание попыток в итоге выглядит так:

  • Попытка 1 — фиксированная задержка (~7 минут), никак не зависит от периода подписки.
  • Попытки 2…N — распределяются геометрически (каждый следующий интервал вдвое больше предыдущего) уже не по всему окну, а по оставшейся его части, так, чтобы последняя попытка пришлась точно на конец окна.

На выходе получается вот такая картина для подписок разного масштаба (5 попыток, floor = 24 часа, cap = 21 день, доля = 20%):

Период Окно Попытка 1 Попытка 2 Попытка 3 Попытка 4 Попытка 5
1 час 1 сутки 7 мин 1ч 42м 4ч 54м 11ч 16м 24ч
1 месяц 6 дней 7 мин 9ч 43м 1д 4ч 54м 2д 19ч 16м 6 дней
1 год 21 день 7 мин 1д 9ч 43м 4д 4ч 54м 9д 19ч 16м 21 день

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

От бизнес-логики к коду

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

Что я вынес для себя из этой задачи

  1. Не спешить решать «в лоб». Первый инстинкт — экспоненциальный backoff в часах — работает для сетевых ретраев, но плохо подходит для платежей, где главная причина неудачи не техническая, а финансовая.
  2. Искать не константы, а инварианты. Как только выяснилось, что диапазон периодов подписки — от часа до трёх лет, стало ясно: любая абсолютная константа обречена работать хорошо только для одного масштаба. Нужна была пропорция.
  3. У пропорций тоже есть края. Чистый процент от периода даёт абсурдные значения на границах диапазона — отсюда floor и cap.
  4. Разделять техническое и бизнесовое. Первая попытка ловит сбои инфраструктуры и не имеет отношения к финансовой логике клиента — поэтому она живёт по своим правилам, отдельно от остальных.

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

(Просмотрено 1 раз, 1 раз за сегодня)

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *