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

Как устроен 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 мин чтения

Разработка календаря приверженности OldSchool: превращаем отметки о приёме в надёжный день

Месячный календарь приверженности выглядит как одна цветная точка на день. Вот модель событий, правила дневного статуса и математика своевременности — всё на устройстве.

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

Разработка Mintly: как удержать точность таймера фокуса, когда Android хочет убить процесс

У работающего таймера Pomodoro проблема с надёжностью сложнее, чем у одноразового напоминания. Вот как Mintly переживает режим Doze, гибель процесса и дрейф при выключенном экране благодаря foreground-сервису и времени окончания по системным часам.

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

Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах

Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.

#android #engineering #privacy