Планировщик повторной оплаты
Подписка хранит конец периода и условия следующей покупки. Теперь фоновый процесс должен найти наступившую границу, создать заказ продления и попытку провайдера. Один проход scheduler может оборваться на любом шаге, а два процесса могут одновременно выбрать ту же компанию. Поэтому «запустить раз в месяц» недостаточно для правильного результата.
В «Пространстве» продление определяется уникальным source key из компании и начала следующего периода. Локальный /local/tick позволяет изучать проход вручную; полный worker использует тот же метод. Способ оплаты является вымышленным fixture reference, деньги не списываются. Код расположен в RenewalScheduler.cs архива; расписание не запускалось при подготовке.
Один период — одно намерение продления
Кандидат имеет state active и period_end <= now. Scheduler открывает ограниченный системный сеанс и блокирует подписку. Он повторно проверяет состояние, потому что владелец мог отменить продление после общей выборки. Затем формирует source key на прежнюю границу периода.
Ниже приведён законченный фрагмент создания ключа. Период записывается в UTC с неизменяемым форматом. Ключ хранится в заказе, а таблица имеет UNIQUE по компании и source_key.
var sourceKey = $"renew:{subscription.PeriodEnd.UtcDateTime:O}";
var start = subscription.PeriodEnd;
var end = BillingCalendar.NextBoundary(start, subscription.AnchorDay);
Компания уже является частью ключа таблицы, поэтому её UUID не обязательно повторять в строке sourceKey. В provider key используется идентификатор платежной попытки, как раньше. Такая структура разделяет три уровня: календарное намерение, локальную команду и создание объекта провайдера.
Если scheduler приходит повторно, он находит тот же заказ. Если два процесса приходят одновременно, блокировка и уникальность не позволяют создать две независимые покупки одной границы. Простое поле last_run_at не даёт такой гарантии: процесс может записать его до создания заказа или остановиться между созданием и отметкой.
Условия копируются из подписки
Цена, валюта, код и версия берутся из сохранённой подписки. Каталог новых предложений здесь не перечитывается для автоматической смены суммы. Это продолжает правило предыдущего урока: изменение Team v2 само по себе не меняет условия компаний, которые приняли v1.
Заказ продления содержит новый интервал, но подписка ещё не получает его как оплаченный. Она ждёт succeeded платежа. Если сразу увеличить period_end при создании попытки, отменённый платеж бесплатно продлил бы доступ. Напротив, сохранённый заказ позволяет знать, какой именно период должен быть выдан после подтверждения.
Способ оплаты представлен ссылкой, полученной при согласованной первоначальной оплате. В настоящей ЮKassa повторные платежи требуют сохранённого способа и соответствующего согласия; механизм описан в разделе автоплатежей. Локальный fixture не получает такого согласия и не используется как образец банковского договора.
Если способ отсутствует, scheduler не должен подставлять случайную прежнюю карту или silently создавать интерактивный платеж. В нашем контракте он отмечает needs_action и не проводит автоматическую попытку. Клиенту требуется отдельное понятное действие. Смена способа тоже должна происходить через подтверждённую команду, а не текстовое поле произвольного reference.
Платеж создаётся после commit
Scheduler фиксирует renewal-заказ и платежное намерение, освобождает блокировку, затем вызывает адаптер. Если процесс остановился до вызова, следующая сверка продолжит существующую попытку. Если остановился после создания fixture, сохранённый provider key вернёт тот же объект. Так пауза не превращается в повторную покупку периода.
Pending fixture не открывает новый доступ. Для ручного сценария его можно перевести в succeeded, после чего ApplyPayment обновит период и entitlement. Canceled переводит расписание в past_due по учебному правилу. Оно не стирает старый успешный заказ и не переносит дату назад.
В этом курсе новая независимая попытка после canceled не создаётся автоматически. Иначе короткий loop scheduler мог бы бесконечно пытаться провести оплату. Настоящая политика повторов определяет число попыток, интервалы, причины прекращения и уведомление клиента. Для изучения идемпотентного периода достаточно одной попытки с явным последующим действием.
Если сервис долго не работал и пропустил три месяца, наш пример не создаёт сразу три автоматических платежа. Он обрабатывает ближайшую сохранённую границу и останавливает расписание после существенной просрочки согласно README. Покупка догоняющих периодов является отдельным продуктовым решением. Молчаливое списание за всё пропущенное время нельзя вывести только из поля period_end.
Отмена и проход scheduler получают порядок
Владелец может отменить подписку одновременно с наступившим продлением. Оба действия блокируют одну строку подписки. Если отмена завершилась первой, scheduler увидит cancel_at_end и не создаст заказ. Если заказ уже зафиксирован, отмена будущего расписания не доказывает, что существующая попытка исчезла.
Такую попытку нужно отдельно сверить или отменить по поддерживаемому контракту провайдера. В локальном примере ручная отмена pending fixture отражается через общий ApplyPayment. Следующий урок подробно объяснит разницу между отключением расписания, отменой попытки и возвратом успешной оплаты.
Наблюдение повторного tick
Для ручного изучения задайте локальную дату после period_end или используйте предоставленную команду имитации времени. Первый tick ожидаемо создаёт один renewal order. Второй до подтверждения находит тот же source key и тот же paymentId. Новая граница entitlement пока не появилась, потому что fixture остаётся pending.
Затем переведите fixture в succeeded и выполните сверку. Ожидаемо period_end двигается на календарную границу заказа. Следующий tick до новой даты ничего не создаёт. Сравнивайте количество заказов и их source key, а не только число строк журнала: журнал может честно содержать несколько проходов для одного намерения.
Ручные часы доступны только лаборатории
В архиве есть LabClock и локальный маршрут смены времени. Он позволяет рассмотреть февраль и границу месяца без ожидания календаря. Значение применяется к биллингу и entitlement, но не меняет часы PostgreSQL и сроки обычных экспортов SaaS. Это явное ограничение учебного сценария: не сравнивайте результат lease_until с искусственно сдвинутым now биллинга. Настоящий сервис использует общую согласованную шкалу и не публикует такую команду клиенту.
Теперь расписание выдерживает повторный запуск и не путает планирование с оплатой. Следующий урок добавляет отмену будущего продления, сохраняя уже оплаченный доступ и объясняя поведение незавершённой попытки на границе.