Как устроен Subly: календарная математика за датами продления подписок
Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.
«Когда это продлится?» звучит как простой запрос, а не вычисление. На самом деле это не так. Как только подписка списывается ежемесячно и началась 31 числа, или списывается ежегодно и началась 29 февраля, ответ перестаёт быть очевидным — а Subly должен получать его правильно каждый раз, потому что неверное напоминание о продлении хуже, чем его отсутствие.
Вот календарная математика, которая тихо работает за датой «следующего списания» в Subly, и граничные случаи, которые усложнили её больше, чем кажется на первый взгляд.
Наивная версия ломается уже на втором месяце
Первый инстинкт — сохранить дату начала и просто прибавлять к ней интервал:
fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
when (cycle) {
BillingCycle.MONTHLY -> start.plusMonths(1)
BillingCycle.YEARLY -> start.plusYears(1)
BillingCycle.WEEKLY -> start.plusWeeks(1)
}
Это работает для подписки, начавшейся 10 числа. Но тихо ломается для той, что началась 31 января. LocalDate.plusMonths в библиотеке дат Kotlin уже сводит 31 февраля к 28 (или 29) числу — что верно для этого перехода, но баг проявляется в следующем месяце. Если продолжать вызывать plusMonths(1) от предыдущей вычисленной даты, а не от исходного дня биллинга, подписка, начавшаяся 31 числа, навсегда съезжает к 28-му и никогда не возвращается к 31-му, даже в месяцы, где такой день есть.
Реальные биллинговые системы не съезжают. То, что Netflix списывает деньги 31 числа в 31-дневном месяце, после того как списал их 28 февраля, — нормальное и ожидаемое поведение, а не баг, который нужно «исправлять», запоминая уменьшенную дату.
Привязка к дню биллинга, а не к последнему счёту
Subly хранит день-якорь — день месяца, в который подписка должна списываться, — отдельно от текущей истории вычисленных дат:
@Entity(tableName = "subscriptions")
data class Subscription(
@PrimaryKey val id: String,
val cycle: BillingCycle,
val anchorDay: Int, // 1..31, "настоящий" день биллинга
val anchorMonth: Int?, // используется только для цикла YEARLY
val startDate: LocalDate,
)
fun nextChargeDate(sub: Subscription, from: LocalDate): LocalDate {
var candidate = from.withDayOfMonthClamped(sub.anchorDay)
while (!candidate.isAfter(from)) {
candidate = candidate.plusMonths(1).withDayOfMonthClamped(sub.anchorDay)
}
return candidate
}
private fun LocalDate.withDayOfMonthClamped(day: Int): LocalDate {
val lastDayOfThisMonth = lengthOfMonth()
return withDayOfMonth(minOf(day, lastDayOfThisMonth))
}
Каждое вычисление начинается с дня-якоря и ограничивается только для этого месяца. Февраль получает 28 (или 29), март сразу возвращается к 31. Ничто не помнит про сжатие предыдущего месяца. Это единственное изменение устранило все случаи «почему моё продление в марте показывает 28-е», встреченные при тестировании.
Годовым циклам тоже нужен месяц-якорь
У годовых подписок своя ловушка: 29 февраля. Подписке, привязанной к високосному дню, нужно явное правило для трёх лет из четырёх, когда такой даты не существует. Subly сводит дату к 28 февраля в невисокосные годы, а не тихо перескакивает на 1 марта — это отражает то, как поступает большинство реальных биллинговых провайдеров, и лучше соответствует ожиданиям пользователя («продлевается в конце февраля»), чем дата, которая иногда перескакивает на целый день вперёд.
Хранение anchorMonth вместе с anchorDay для годовых циклов — вместо того чтобы заново выводить месяц из единственной startDate, которая сама может быть високосным днём, — оставляет это простым запросом, а не ещё одним особым случаем, разбросанным по коду.
Пробный период — это смена цикла, а не новая подписка
Переход бесплатного пробного периода в платный — это не новая подписка со своим якорем, а та же самая подписка, чей первый платный платёж и задаёт день-якорь. Моделирование этого как двух отдельных строк было первым решением, которое развалилось при тестировании: оно удваивало напоминания о продлении и теряло связь между «пробный период начался» и «карта списана». Вместо этого Subly хранит одну строку и поле trialEndsAt; логика напоминаний сначала проверяет это поле и возвращается к обычному вычислению nextChargeDate только после того, как пробный период действительно перешёл в платный.
Напоминания планируются от вычисленной даты, а не от таймера
Поскольку nextChargeDate — чистая функция от сохранённых данных, Subly никогда не нуждается в фоновой задаче, ведущей обратный отсчёт. Задача WorkManager раз в день заново вычисляет ближайшие даты продления для всех подписок, сравнивает их с уже запланированными и трогает очередь уведомлений только там, где что-то изменилось. Если изменить день биллинга подписки, следующий же дневной проход выдаст исправленное напоминание — нет никакого съезжающего таймера, который нужно было бы аннулировать, потому что изначально ничего и не отсчитывалось.
Почему это стоит делать хорошо
Ничему из этого не нужен сервер. Это чистая арифметика дат над LocalDate, выполняющаяся за долю миллисекунды на подписку — из тех вещей, которые легко сделать чуть-чуть неправильно и никогда этого не заметить, пока напоминание о продлении не сработает на день позже для того, кто платит вперёд за год. Правильно разобраться с граничными случаями один раз, в одной функции, дешевле, чем отлаживать жалобы пользователей на подписку, которая «продлевается каждый год в разный день».
Subly доступен в App Store и Google Play, если хотите увидеть напоминания с принимающей стороны.
// По теме
Ещё из журнала
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.
R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе
Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.
Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его
Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.