internal/recurring — пакете без базы данных, без
транспорта и без импорта провайдеров, поэтому они покрыты модульными тестами
сами по себе. Воркер в internal/renewal владеет границами транзакций и
вызовами провайдера. Именно из-за этого разделения расписание ниже читается как
спецификация, а не как описание одного пути в коде.
Автопродление включается только явно
Нет ни значения по умолчанию, ни унаследованной настройки, ни «включено, потому что тариф это поддерживает». Настройка несёт момент, когда клиент согласился, и настройка без записи о согласии непригодна для списания, как бы она ни оказалась включена: решение о движении денег зависит от свидетельства согласия, а не от булева поля, которое случайно оказалось истинным. База данных обеспечивает то же самое, а не полагается на приложение: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 и записавшая успешную проверку возможности, списания с
карты всё равно не увидит. Напоминания о продлении, описанные в
Подписки, в любом случае доставляются по тому же
расписанию за семь, три, один и ноль дней.
Смежные страницы
Заказы и платежи
Машина состояний заказа, провайдеры, вебхуки и возвраты.
Платёжные провайдеры
Настройка по каждому провайдеру, включая переключатель возможности регулярных платежей.
Подписки
Арифметика продления, напоминания, льготный период и восстановление.
Кошелёк и книга проводок
Как продление за счёт кошелька рассчитывается и записывается.