Перейти к содержимому
Все записи

Как устроен Subly: календарная математика за датами продления подписок

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

MFKAPPS 4 мин чтения

«Когда это продлится?» звучит как простой запрос, а не вычисление. На самом деле это не так. Как только подписка списывается ежемесячно и началась 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, если хотите увидеть напоминания с принимающей стороны.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

#android #engineering #room
MFKAPPS 4 мин чтения

R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе

Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room