Skip to main content
Автоматическое списание — единственное место в продукте, где деньги двигаются без чьего-либо наблюдения. Им управляют три правила, и все три именно обеспечиваются, а не просто описаны. Сами правила живут в internal/recurring — пакете без базы данных, без транспорта и без импорта провайдеров, поэтому они покрыты модульными тестами сами по себе. Воркер в internal/renewal владеет границами транзакций и вызовами провайдера. Именно из-за этого разделения расписание ниже читается как спецификация, а не как описание одного пути в коде.
Текущее состояние реализации. У автопродления есть одна поверхность — Telegram-бот, — которая включает и выключает его для каждой подписки отдельно. Ни один путь в коде не сохраняет способ оплаты, поэтому кошелёк — единственный источник средств, который клиент может выбрать. Правила, расписание и операторские поверхности чтения, описанные на этой странице, реализованы. Раздел Что пока не работает точно указывает, какие части — нет, чтобы оператор понимал, чего ожидать до включения провайдера для регулярных списаний.

Автопродление включается только явно

Нет ни значения по умолчанию, ни унаследованной настройки, ни «включено, потому что тариф это поддерживает». Настройка несёт момент, когда клиент согласился, и настройка без записи о согласии непригодна для списания, как бы она ни оказалась включена: решение о движении денег зависит от свидетельства согласия, а не от булева поля, которое случайно оказалось истинным. База данных обеспечивает то же самое, а не полагается на приложение: auto_renew_settings несёт CHECK (enabled = false OR consent_at IS NOT NULL), поэтому строку, которая заявляет о включении без свидетельства согласия, вообще нельзя записать. Существующие строки из v0.5 были заполнены моментом последней записи настройки, потому что те строки создавались клиентом, явно включавшим автопродление в боте.

Четыре состояния

Успех возвращает цикл в scheduled, а не в idle — согласие клиента не изменилось, и следующий период будет отработан в свою очередь.

Провайдер должен уметь и иметь разрешение

Два условия, оба обязательны:
  • Адаптер объявляет, способен ли он вообще привязать способ оплаты. Это зашито при компиляции, через payments.Capabilities.Recurring. Из поставляемых адаптеров это объявляет только YooKassa; Telegram Stars и CryptoBot — нет.
  • Собственный мерчант-аккаунт оператора должен пройти проверку возможности, результат которой записан в payment_provider_settings. Ряд эквайеров выдаёт привязку карт по мерчанту, а не по интеграции, поэтому одного слова адаптера недостаточно.
Переключатель оператора может только сузить то, что объявил адаптер, но никогда не расширить. База данных отклоняет recurring_enabled = true при чём-либо, кроме успешно пройденной проверки, а API отказывается включать регулярные платежи для адаптера, который не объявляет эту возможность, — так что строка не может заявлять о возможности, которой у интеграции нет. Чтобы записать результат проверки, вызовите POST /v1/panel/settings/providers/{provider}/recurring с правом settings.write, передав {merchantId, passed, enable}. Попытка включить адаптер, не объявляющий эту возможность, возвращает 422 recurring_unsupported с сообщением, что этот платёжный адаптер не может сохранять способ оплаты. Настройка по каждому провайдеру описана в Платёжные провайдеры.

Сохранённые способы оплаты не содержат платёжного инструмента

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

Запас времени до окончания

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

Повторные попытки списания

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

Ключи цикла

Попытки уникальны по паре (cycle_key, attempt), где ключ цикла идентифицирует один период продления одной подписки: идентификатор права на услугу и момент его окончания с точностью до секунды, в UTC. То же самое выражение вычисляется в SQL запросом, который находит подошедшие продления, поэтому воркер и ограничение уникальности согласны в том, что такое «это продление». Повторно проигранное задание ничего не вставляет и читает обратно уже существующую попытку. Точности до секунды достаточно, потому что момент окончания права на услугу не двигается внутри цикла — а если двигается, то это по определению другой цикл.

Коды отказов

Код хранится в строке попытки, где его может прочитать поддержка, а не показывается клиенту: insufficient_wallet_balance — операторская лексика.

Что на самом деле делает воркер

Один цикл берёт деньги клиента максимум один раз, и это держится на трёх механизмах, а не на аккуратности. Расписание — это таблица. dunning_attempts несёт номер попытки, время её наступления и её исход, с уникальным ключом по (cycle_key, attempt). Перезапущенный воркер продолжает по ней, а не начинает заново, и продублированный проход ничего не вставляет. Запись строки попытки до какого-либо списания — это то, что делает списание возобновляемым: воркер, умерший между планированием и списанием, оставляет запланированную попытку, которую найдёт следующий проход, а не продление, которое молча никогда не произошло. Все попытки в цикле разделяют один заказ. Ключ идемпотентности заказа — renewal:<cycleKey>, поэтому повтор находит заказ, открытый предыдущей попыткой, а не выставляет счёт за второй период. Ключ провайдера — уже renewal:<cycleKey>:<attempt>, поэтому повтор этой попытки попадает в тот же платёж у провайдера, тогда как более поздняя попытка — это по-настоящему новое списание по заказу, который всё ещё не оплачен. Поскольку один заказ обслуживает всё расписание, он открывается с окном оплаты в семьдесят шесть часов — сорок часов окна дозвона плюс полтора дня на отложенные попытки, — а не с часовым окном, которое получает обычный чекаут, так что четвёртая попытка всё ещё находит его оплачиваемым. Заказ, который всё же истёк или был отменён, проваливает попытку с кодом order_expired или order_cancelled: списать по нему ничего нельзя. Продление за счёт кошелька открывается только тогда, когда баланс его покрывает. Каждая попытка за счёт кошелька сначала рассчитывает цену продления и читает доступный баланс таким, каков он в этот момент. Если баланса не хватает, попытка завершается с кодом insufficient_wallet_balance, и заказ не записывается, так что на остаток расписания в кошельке клиента ничего не резервируется, а пополнение, сделанное после первого отказа, увидит следующая попытка. Только когда баланс покрывает цену, заказ создаётся — оплаченным в той же транзакции, с продлённым правом на услугу и поставленной в очередь выдачей доступа. Синхронное закрытие — это закрытие. Списание по сохранённому способу с захватом может вернуться от провайдера уже в статусе succeeded. Этот результат проходит тот же путь классификации и закрытия, который породил бы вебхук, — заказ переходит в paid, право на услугу продлевается, задание выдачи доступа ставится в очередь, — а не записывается только на платёжное намерение. Если это закрытие не закоммитится, намерение сохраняет ссылку провайдера в статусе processing, и сверка платежей закрывает его при следующем проходе; сверка также подбирает намерение в статусе succeeded, чей заказ всё ещё pending, — именно так выглядит списание, записанное без закрытия. Незавершённый платёж блокирует новое списание. Если провайдера уже спросили и он не ответил, попытка откладывается, а не считается неуспешной: честное прочтение молчания — «неизвестно», а повторное списание при неизвестности — это как раз то, как клиента списывают дважды. Каждый исход двигает строку автопродления этой подписки: её state следует за исходом (scheduled после успеха, dunning после отказа, suspended после отказа от цикла), last_attempt_at фиксирует момент, а last_failure_code несёт код из таблицы выше. Удалённая строка настроек оставляет записью сам исход попытки. Согласие перепроверяется в момент списания, а не в момент планирования, потому что именно второй момент двигает деньги. Клиент, выключивший автопродление в промежутке, разрешается как abandoned с кодом consent_withdrawn и не списывается. Клиент, чей аккаунт не active, вообще не попадает в выборку. Отклонённое списание не выводит из обращения сохранённый способ оплаты. Только адаптер, сообщающий, что возможность не поддерживается, либо ошибка, называющая сам способ оплаты, помечают способ как failed — дневной лимит или временная блокировка отклоняют совершенно исправную карту, и её забвение превратило бы один неудачный день в потерянную подписку. Проход выполняется каждую минуту в процессе воркера, по сто строк за раз: сначала планирует то, что подошло по времени, затем списывает то, что запланировано. Ни интервал, ни размер пакета не вынесены в переменную окружения. Воркер собирает тот же набор адаптеров, что и API, потому что воркер без адаптера мог бы только фиксировать отказы.

Чем управляет клиент

Всё на этой странице, что клиент может изменить, живёт в Telegram-боте, в разделе Settings → Auto-renew (маршрут autorenew). Экран сообщает три вещи, которые нужно знать перед согласием на списание, при котором клиент не будет присутствовать: какой тариф, откуда и за сколько времени до истечения срока. Он также называет словами текущее состояние, когда оно не обычное, чтобы клиенту, у которого только что не прошла карта, не приходилось выяснять по молчащему экрану, почему ничего не произошло. Каждая настройка принадлежит одной подписке — таблица ключуется по (user_id, subscription_id), — поэтому каждый callback ниже несёт подписку, которую меняет, последним сегментом. При включённых одновременных подписках маршрут сначала спрашивает, какую подписку настроить (renew-settings:<subscriptionID>); на установке с одной подпиской он разрешается в основную. Если ни один включённый провайдер не объявляет поддержку регулярных платежей для валюты клиента, экран сообщает об этом и не предлагает ничего включать, вместо того чтобы показывать переключатель, который всё равно бы не сработал. Именно включение автопродления записывает согласие — воркер проверяет эту отметку времени, а не флаг, поэтому строка, записанная импортом или миграцией, никогда не может породить списание. Выключение намеренно не равнозначно просьбе забыть карту: сохранённые способы оплаты остаются. Переключение источника средств на сохранённый способ оплаты при отсутствии активного способа возвращает отдельное сообщение «нет способа оплаты», а не общую ошибку. Удаление последнего активного способа откатывает автопродление на кошелёк и приостанавливает его, вместо того чтобы оставлять клиента согласившимся на списание, которое больше нельзя выполнить; запись о согласии сохраняется, поэтому повторное добавление карты не означает повторного согласия. Продление за счёт кошелька тратит баланс, а продление картой его не трогает. Молчаливое опустошение кошелька клиента только потому, что его карта сохранена, было бы иным соглашением, чем то, которое он заключил. См. Кошелёк и книга проводок про то, как применяется баланс.

О каких отказах клиент узнаёт

Не о каждом. Первое отклонение обычно решается повтором, а уведомление на каждую попытку приучает клиента их игнорировать. Второй отказ и окончательный отказ от попыток — это те два события, которые меняют то, что клиенту нужно сделать, и отправляются именно они. Решение хранится на попытке в поле notify_required, а не вычисляется заново в запросе нотификатора, поэтому правило живёт в одном месте. Сообщение говорит, сохраняется ли доступ и что делать. Уведомления о неуспешных списаниях намеренно обходят тихие часы и настройки уведомлений. Это платёжное уведомление о собственной подписке клиента, того же класса, что и чек: клиенту, который вот-вот потеряет доступ из-за отклонённой карты, не поможет удержание сообщения до утра, и в модели предпочтений для него нет отказа от подписки. Тем не менее уведомление ограничено: оно пробуется не более чем в трёх проходах, прекращается сразу, когда Telegram сообщает, что чат заблокирован или не начат, не отправляется клиенту с заведомо недоступным чатом и не отправляется, если ему уже неделя. Всё остальное, что получает клиент, описано в Уведомления.

Что показывает панель

Очередь неуспешных списаний в разделе Finance → Failed charges (GET /v1/panel/finance/failed-charges, finance.read) — это поверхность для разбора, а не для управления. Ничто в ней не запускает списание повторно: клиенту уже сказали продлить, и списание по клику в панели было бы попыткой, о которой он не просил. История списаний клиента (GET /v1/panel/customers/{customerID}/charges) и его сохранённые способы оплаты (GET /v1/panel/customers/{customerID}/payment-methods) отображаются в его профиле, обе за правом finance.read. Ни токен провайдера, ни какие-либо данные карты не возвращаются ни одной из них. Оператор с правом finance.write может отозвать способ оплаты от имени клиента через DELETE /v1/panel/customers/{customerID}/payment-methods/{methodID}. Эндпоинта автопродления нет ни под /v1/account, ни под /v1/panel. Оператор вообще не может включить, выключить или перенастроить автопродление клиента, что согласуется с тем, что согласие даёт клиент.

Что пока не работает

Это свойства поставляемого кода, а не конфигурации. Ни один путь кода не создаёт сохранённый способ оплаты. Запрос, который его сохранял бы, существует и не имеет вызывающих, а адаптер YooKassa никогда не просит провайдера сохранить способ оплаты, поэтому токен для привязки никогда не возвращается. Список сохранённых способов у каждого клиента поэтому пуст, funding = 'saved_method' выбрать нельзя, и единственный достижимый источник средств — кошелёк. Логика удаления и отзыва, описанная выше, реализована; создание — нет. У автопродления нет ни веб-, ни панельной поверхности. Оно не появляется ни в клиентской веб-панели, ни в /admin — за исключением истории списаний только для чтения и переключателя возможности провайдера. В совокупности: автопродление из кошелька работает от начала до конца — бот его включает, воркер планирует цикл, списывает с кошелька за указанный срок до окончания, повторяет попытки по расписанию dunning и приостанавливает после последней неудачи. Автопродление с сохранённой карты пока невозможно: ни один путь не создаёт сохранённый метод, поэтому установка, включившая регулярные платежи для YooKassa и записавшая успешную проверку возможности, списания с карты всё равно не увидит. Напоминания о продлении, описанные в Подписки, в любом случае доставляются по тому же расписанию за семь, три, один и ноль дней.

Смежные страницы

Заказы и платежи

Машина состояний заказа, провайдеры, вебхуки и возвраты.

Платёжные провайдеры

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

Подписки

Арифметика продления, напоминания, льготный период и восстановление.

Кошелёк и книга проводок

Как продление за счёт кошелька рассчитывается и записывается.