Building Subly: the calendar math behind subscription renewal dates
Predicting a subscription's next charge date sounds trivial until you hit month-end billing, leap years, and trial conversions. Here's how Subly gets it right, on-device.
“When does this renew?” sounds like a lookup, not a computation. It isn’t. The moment a subscription bills monthly and started on the 31st, or bills yearly and started on February 29th, the answer stops being obvious — and Subly needs to get it right every time, because a wrong renewal reminder is worse than no reminder at all.
Here’s the calendar math that runs quietly behind Subly’s “next charge” date, and the edge cases that made it harder than it looks.
The naive version breaks in month two
The first instinct is to store a start date and just add an interval:
fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
when (cycle) {
BillingCycle.MONTHLY -> start.plusMonths(1)
BillingCycle.YEARLY -> start.plusYears(1)
BillingCycle.WEEKLY -> start.plusWeeks(1)
}
This works for a subscription that started on the 10th. It quietly breaks for one that started on January 31st. LocalDate.plusMonths in Java/Kotlin’s date library already clamps February 31st down to February 28th (or 29th) — which is correct for that transition, but the bug shows up the month after. If you keep chaining plusMonths(1) off the previous computed date instead of the original billing day, a subscription that started on the 31st permanently drifts to the 28th and never returns to the 31st in months that have one.
Real billing systems don’t drift. Netflix charging you on the 31st in a 31-day month, having billed you on the 28th in February, is the normal and expected behavior — not a bug to “fix” by remembering the shrunken date.
Anchor on the billing day, not the last invoice
Subly stores the anchor day — the day-of-month the subscription is meant to bill on — separately from the running history of computed dates:
@Entity(tableName = "subscriptions")
data class Subscription(
@PrimaryKey val id: String,
val cycle: BillingCycle,
val anchorDay: Int, // 1..31, the "true" billing day
val anchorMonth: Int?, // only used for YEARLY cycles
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))
}
Every computation starts from the anchor day and clamps for that month only. February gets 28 (or 29), March goes straight back to 31. Nothing remembers last month’s shrink. This is the single change that fixed every “why did my March renewal show the 28th” case during testing.
Yearly cycles need an anchor month too
Yearly subscriptions have their own trap: February 29th. A subscription anchored on a leap day needs an explicit rule for the three years out of four when that date doesn’t exist. Subly clamps to February 28th in non-leap years rather than silently jumping to March 1st — mirroring what most real billing providers do, and matching user expectation (“it renews at the end of February”) better than a date that occasionally jumps a full day forward.
Storing anchorMonth alongside anchorDay for yearly cycles — rather than re-deriving the month from a single startDate that could itself be a leap day — keeps this a straightforward lookup instead of another special case scattered through the code.
Trials are a cycle change, not a new subscription
A free trial converting to paid isn’t a new subscription with its own anchor — it’s the same subscription whose first paid charge sets the anchor day. Modeling it as two separate rows was the first design that fell apart in testing: it doubled the renewal reminders and lost the link between “trial started” and “card gets charged.” Subly instead keeps one row and a trialEndsAt field; the reminder logic checks that field first and only falls back to the recurring nextChargeDate calculation once the trial has actually converted.
Reminders schedule off the computed date, not a timer
Because nextChargeDate is a pure function of stored data, Subly never needs a background job counting down. A WorkManager task re-derives the next few renewal dates for all subscriptions once a day, diffs them against what’s already scheduled, and only touches the notification queue for what changed. If you edit a subscription’s billing day, the very next daily pass produces a corrected reminder — there’s no drifting timer to invalidate, because nothing was ever counted down in the first place.
Why this stays worth doing well
None of this needs a server. It’s pure date arithmetic against a LocalDate, and it runs in a fraction of a millisecond per subscription — the kind of thing that’s easy to get slightly wrong and never notice, until a renewal reminder fires a day late for someone who prepays annually. Getting the edge cases right once, in one function, is cheaper than debugging user reports about a subscription that “renews on a different day every year.”
Subly is live on the App Store and Google Play if you want to see the reminders on the receiving end.
// Related reading
More from the journal
Building OldSchool's adherence calendar: turning dose taps into a day you can trust
A monthly adherence calendar looks like a single colored dot per day. Here's the event model, the day-status rules, and the on-time math behind it, on-device.
Building Mintly: keeping a focus timer accurate when Android kills the process
A running Pomodoro timer has a harder reliability problem than a one-shot reminder. Here's how Mintly survives Doze, process death, and screen-off drift with a foreground service and a wall-clock end time.
Building Granyn: a budget tracker with no bank login, in three tables
How Granyn tracks spending across currencies and catches recurring bills without linking a single bank account — the Room schema and the trade-offs behind it.