Saltar al contenido
Todas las entradas

Construyendo Subly: las matemáticas de calendario detrás de las fechas de renovación de suscripciones

Predecir la próxima fecha de cobro de una suscripción parece trivial hasta que te topas con la facturación a fin de mes, los años bisiestos y las conversiones de prueba. Así es como Subly lo resuelve, en el dispositivo.

MFKAPPS 5 min de lectura

“¿Cuándo se renueva esto?” suena a una simple consulta, no a un cálculo. No lo es. En el momento en que una suscripción se factura mensualmente y empezó el día 31, o se factura anualmente y empezó un 29 de febrero, la respuesta deja de ser obvia — y Subly necesita acertarla siempre, porque un recordatorio de renovación equivocado es peor que no tener recordatorio alguno.

Aquí está la matemática de calendario que funciona en silencio detrás de la fecha de “próximo cobro” de Subly, y los casos límite que lo hicieron más difícil de lo que parece.

La versión ingenua se rompe en el segundo mes

El primer instinto es guardar una fecha de inicio y simplemente sumarle un intervalo:

fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
    when (cycle) {
        BillingCycle.MONTHLY -> start.plusMonths(1)
        BillingCycle.YEARLY -> start.plusYears(1)
        BillingCycle.WEEKLY -> start.plusWeeks(1)
    }

Esto funciona para una suscripción que empezó el día 10. Se rompe silenciosamente para una que empezó el 31 de enero. LocalDate.plusMonths en la librería de fechas de Kotlin ya reduce el 31 de febrero al 28 (o 29) — lo cual es correcto para esa transición, pero el error aparece el mes siguiente. Si sigues encadenando plusMonths(1) a partir de la fecha calculada anterior en lugar del día de facturación original, una suscripción que empezó el 31 deriva permanentemente hacia el 28 y nunca vuelve al 31 ni siquiera en los meses que sí lo tienen.

Los sistemas de facturación reales no derivan. Que Netflix te cobre el 31 en un mes de 31 días, después de haberte cobrado el 28 en febrero, es el comportamiento normal y esperado — no un error que haya que “corregir” recordando la fecha reducida.

Anclarse al día de facturación, no a la última factura

Subly guarda el día ancla — el día del mes en el que la suscripción debe facturarse — por separado del historial corriente de fechas calculadas:

@Entity(tableName = "subscriptions")
data class Subscription(
    @PrimaryKey val id: String,
    val cycle: BillingCycle,
    val anchorDay: Int,       // 1..31, el día de facturación "real"
    val anchorMonth: Int?,    // solo se usa para ciclos 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))
}

Cada cálculo parte del día ancla y se ajusta solo para ese mes. Febrero recibe 28 (o 29), marzo vuelve directamente al 31. Nada recuerda la reducción del mes anterior. Este fue el único cambio que resolvió todos los casos de “por qué mi renovación de marzo muestra el 28” durante las pruebas.

Los ciclos anuales también necesitan un mes ancla

Las suscripciones anuales tienen su propia trampa: el 29 de febrero. Una suscripción anclada a un día bisiesto necesita una regla explícita para los tres años de cada cuatro en los que esa fecha no existe. Subly la reduce al 28 de febrero en los años no bisiestos en lugar de saltar silenciosamente al 1 de marzo — reflejando lo que hacen la mayoría de los proveedores de facturación reales, y encajando mejor con la expectativa del usuario (“se renueva a fin de febrero”) que una fecha que ocasionalmente salta un día entero hacia adelante.

Guardar anchorMonth junto con anchorDay para los ciclos anuales — en lugar de rederivar el mes a partir de una única startDate que podría ser en sí misma un día bisiesto — mantiene esto como una simple consulta en vez de otro caso especial disperso por el código.

Las pruebas gratuitas son un cambio de ciclo, no una nueva suscripción

Una prueba gratuita que se convierte en pago no es una nueva suscripción con su propio ancla — es la misma suscripción cuyo primer cobro de pago fija el día ancla. Modelarlo como dos filas separadas fue el primer diseño que se vino abajo durante las pruebas: duplicaba los recordatorios de renovación y perdía el vínculo entre “empezó la prueba” y “se cobra la tarjeta”. En su lugar, Subly mantiene una sola fila y un campo trialEndsAt; la lógica de recordatorios comprueba primero ese campo y solo recurre al cálculo recurrente de nextChargeDate una vez que la prueba realmente se ha convertido.

Los recordatorios se programan según la fecha calculada, no un temporizador

Como nextChargeDate es una función pura de los datos guardados, Subly nunca necesita una tarea en segundo plano contando hacia atrás. Una tarea de WorkManager rederiva una vez al día las próximas fechas de renovación de todas las suscripciones, las compara con lo que ya está programado, y solo toca la cola de notificaciones para lo que cambió. Si editas el día de facturación de una suscripción, el siguiente paso diario produce un recordatorio corregido de inmediato — no hay ningún temporizador a la deriva que invalidar, porque nunca hubo nada contando hacia atrás en primer lugar.

Por qué vale la pena hacer esto bien

Nada de esto necesita un servidor. Es aritmética de fechas pura sobre un LocalDate, y se ejecuta en una fracción de milisegundo por suscripción — el tipo de cosa fácil de hacer ligeramente mal sin darse cuenta nunca, hasta que un recordatorio de renovación se dispara un día tarde para alguien que paga anualmente por adelantado. Acertar los casos límite una vez, en una sola función, sale más barato que depurar reportes de usuarios sobre una suscripción que “se renueva en un día distinto cada año”.

Subly está disponible en la App Store y Google Play si quieres ver los recordatorios desde el otro lado.