> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Регулярные платежи

> Согласие на автопродление, расписание списаний, ограниченные повторы неуспешных списаний и то, что клиент может отключить

Автоматическое списание — единственное место в продукте, где деньги двигаются
без чьего-либо наблюдения. Им управляют три правила, и все три именно
обеспечиваются, а не просто описаны.

Сами правила живут в `internal/recurring` — пакете без базы данных, без
транспорта и без импорта провайдеров, поэтому они покрыты модульными тестами
сами по себе. Воркер в `internal/renewal` владеет границами транзакций и
вызовами провайдера. Именно из-за этого разделения расписание ниже читается как
спецификация, а не как описание одного пути в коде.

> [!WARNING]
>
> **Текущее состояние реализации.** У автопродления есть одна поверхность —
> Telegram-бот, — которая включает и выключает его для каждой подписки отдельно.
> Ни один путь в коде не сохраняет способ оплаты, поэтому кошелёк — единственный
> источник средств, который клиент может выбрать. Правила, расписание и
> операторские поверхности чтения, описанные на этой странице, реализованы.
> Раздел [Что пока не работает](#что-пока-не-работает) точно указывает, какие
> части — нет, чтобы оператор понимал, чего ожидать до включения провайдера для
> регулярных списаний.

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

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

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

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

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

| Состояние | Значение |
| - | - |
| `idle` | Автопродление выключено. Воркер на строку не смотрит. |
| `scheduled` | Взведено, ожидает наступления запаса времени до окончания права на услугу. |
| `dunning` | Списание не прошло, и работает расписание повторов. |
| `suspended` | Повторы исчерпаны. Согласие сохраняется; воркер перестаёт брать строку в работу. |

Успех возвращает цикл в `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`
с сообщением, что этот платёжный адаптер не может сохранять способ оплаты.
Настройка по каждому провайдеру описана в
[Платёжные провайдеры](https://omniflow-vpn.mintlify.site/ru/integrations/payments.md).

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

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

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

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

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

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

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

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

Неуспешное списание повторяется ограниченное число раз, а затем прекращается.

| Попытка | Задержка перед ней |
| - | - |
| 1 | немедленно |
| 2 | 4 часа |
| 3 | 12 часов |
| 4 | 24 часа |

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

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

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

### Ключи цикла

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

Точности до секунды достаточно, потому что момент окончания права на услугу не
двигается внутри цикла — а если двигается, то это по определению другой цикл.

### Коды отказов

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

| Код | Что произошло |
| - | - |
| `consent_withdrawn` | Автопродление было выключено или приостановлено между планированием и списанием |
| `plan_unavailable` | Сохранённая версия тарифа или валюта отсутствует |
| `order_failed` | Заказ на продление не удалось создать |
| `order_expired`, `order_cancelled` | Заказ цикла закрылся без оплаты до того, как эта попытка выполнилась |
| `insufficient_wallet_balance` | Продление за счёт кошелька обнаружило, что доступного баланса меньше цены; заказ не открывался |
| `no_payment_method` | Попытка не называет способ оплаты либо строка способа исчезла |
| `payment_method_<status>` | Сохранённый способ оплаты не в статусе `active` |
| `recurring_not_enabled` | Переключатель оператора или проверка возможности этого не позволяют |
| `recurring_unsupported` | Адаптер не умеет списывать по сохранённому способу оплаты |
| `provider_unavailable`, `provider_timeout`, `declined`, `charge_failed` | Провайдер отказал или не завершил операцию |

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

Один цикл берёт деньги клиента максимум один раз, и это держится на трёх
механизмах, а не на аккуратности.

**Расписание — это таблица.** `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>`); на установке с одной подпиской он
разрешается в основную.

| Элемент управления | Callback | Эффект |
| - | - | - |
| Enable | `autorenew:on:<subscriptionID>` | Выбирает первый вариант оплаты, чей адаптер объявляет поддержку регулярных платежей, записывает подписку, тариф, провайдера, валюту, `consent_at` и состояние `scheduled` |
| Disable | `autorenew:off:<subscriptionID>` | Очищает согласие, ставит состояние `idle`, проставляет `cancelled_at` и не трогает сохранённые способы оплаты |
| Funding | `renew-funding:wallet:<subscriptionID>`, `renew-funding:saved_method:<subscriptionID>` | Выбирает, откуда берутся деньги для списания. Кнопка сохранённого способа отрисовывается только когда способ сохранён |
| Lead time | `renew-lead:1:<subscriptionID>`, `:3:…`, `:7:…` | Дней предупреждения до истечения срока |
| Payment methods | маршрут `methods` | Список, `method-default:<id>`, `method-remove:<id>` |

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

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

Переключение источника средств на сохранённый способ оплаты при отсутствии
активного способа возвращает отдельное сообщение «нет способа оплаты», а не общую
ошибку. Удаление последнего активного способа откатывает автопродление на кошелёк
и приостанавливает его, вместо того чтобы оставлять клиента согласившимся на
списание, которое больше нельзя выполнить; запись о согласии сохраняется, поэтому
повторное добавление карты не означает повторного согласия.

Продление за счёт кошелька тратит баланс, а продление картой его не трогает.
Молчаливое опустошение кошелька клиента только потому, что его карта сохранена,
было бы иным соглашением, чем то, которое он заключил. См.
[Кошелёк и книга проводок](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-ledger.md) про то, как применяется
баланс.

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

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

Решение хранится на попытке в поле `notify_required`, а не вычисляется заново в
запросе нотификатора, поэтому правило живёт в одном месте. Сообщение говорит,
сохраняется ли доступ и что делать.

Уведомления о неуспешных списаниях намеренно обходят тихие часы и настройки
уведомлений. Это платёжное уведомление о собственной подписке клиента, того же
класса, что и чек: клиенту, который вот-вот потеряет доступ из-за отклонённой
карты, не поможет удержание сообщения до утра, и в модели предпочтений для него
нет отказа от подписки. Тем не менее уведомление ограничено: оно пробуется не
более чем в трёх проходах, прекращается сразу, когда Telegram сообщает, что чат
заблокирован или не начат, не отправляется клиенту с заведомо недоступным чатом
и не отправляется, если ему уже неделя. Всё остальное, что получает клиент,
описано в [Уведомления](https://omniflow-vpn.mintlify.site/ru/customer/notifications.md).

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

Очередь неуспешных списаний в разделе **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 и записавшая успешную проверку возможности, списания с
карты всё равно не увидит. Напоминания о продлении, описанные в
[Подписки](https://omniflow-vpn.mintlify.site/ru/commerce/subscriptions.md), в любом случае доставляются по тому же
расписанию за семь, три, один и ноль дней.

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

> **[Заказы и платежи](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md)**
>
> Машина состояний заказа, провайдеры, вебхуки и возвраты.

> **[Платёжные провайдеры](https://omniflow-vpn.mintlify.site/ru/integrations/payments.md)**
>
> Настройка по каждому провайдеру, включая переключатель возможности регулярных платежей.

> **[Подписки](https://omniflow-vpn.mintlify.site/ru/commerce/subscriptions.md)**
>
> Арифметика продления, напоминания, льготный период и восстановление.

> **[Кошелёк и книга проводок](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-ledger.md)**
>
> Как продление за счёт кошелька рассчитывается и записывается.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.