Construire Subly : les calculs de calendrier derrière les dates de renouvellement d'abonnement
Prédire la prochaine date de prélèvement d'un abonnement semble trivial jusqu'à ce qu'on tombe sur la facturation en fin de mois, les années bissextiles et les conversions d'essai. Voici comment Subly y arrive, sur l'appareil.
« Quand ça se renouvelle-t-il ? » ressemble à une simple consultation, pas à un calcul. Ce n’en est pas une. Dès qu’un abonnement est facturé mensuellement et a commencé le 31, ou facturé annuellement et a commencé un 29 février, la réponse cesse d’être évidente — et Subly doit la trouver correctement à chaque fois, car un rappel de renouvellement erroné est pire que pas de rappel du tout.
Voici les calculs de calendrier qui tournent discrètement derrière la date de « prochain prélèvement » de Subly, et les cas particuliers qui ont rendu la tâche plus difficile qu’il n’y paraît.
La version naïve casse dès le deuxième mois
Le premier réflexe est de stocker une date de début et d’y ajouter simplement un intervalle :
fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
when (cycle) {
BillingCycle.MONTHLY -> start.plusMonths(1)
BillingCycle.YEARLY -> start.plusYears(1)
BillingCycle.WEEKLY -> start.plusWeeks(1)
}
Cela fonctionne pour un abonnement commencé le 10. Cela casse silencieusement pour un abonnement commencé le 31 janvier. LocalDate.plusMonths dans la bibliothèque de dates de Kotlin ramène déjà le 31 février au 28 (ou 29) — ce qui est correct pour cette transition, mais le bug apparaît le mois suivant. Si vous continuez à enchaîner plusMonths(1) à partir de la date précédemment calculée plutôt que du jour de facturation d’origine, un abonnement qui a commencé le 31 dérive définitivement vers le 28 et ne revient jamais au 31, même les mois qui en comptent un.
Les vrais systèmes de facturation ne dérivent pas. Que Netflix vous facture le 31 dans un mois de 31 jours, après vous avoir facturé le 28 en février, est un comportement normal et attendu — pas un bug à « corriger » en se souvenant de la date rétrécie.
S’ancrer sur le jour de facturation, pas sur la dernière facture
Subly stocke le jour d’ancrage — le jour du mois où l’abonnement est censé être facturé — séparément de l’historique courant des dates calculées :
@Entity(tableName = "subscriptions")
data class Subscription(
@PrimaryKey val id: String,
val cycle: BillingCycle,
val anchorDay: Int, // 1..31, le "vrai" jour de facturation
val anchorMonth: Int?, // utilisé uniquement pour les cycles 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))
}
Chaque calcul part du jour d’ancrage et se limite pour ce mois-là uniquement. Février obtient 28 (ou 29), mars revient directement à 31. Rien ne se souvient du rétrécissement du mois précédent. C’est le seul changement qui a corrigé chaque cas de « pourquoi mon renouvellement de mars affiche-t-il le 28 » rencontré pendant les tests.
Les cycles annuels ont aussi besoin d’un mois d’ancrage
Les abonnements annuels ont leur propre piège : le 29 février. Un abonnement ancré sur un jour bissextile a besoin d’une règle explicite pour les trois années sur quatre où cette date n’existe pas. Subly ramène au 28 février les années non bissextiles plutôt que de sauter silencieusement au 1er mars — reflétant ce que font la plupart des vrais fournisseurs de facturation, et correspondant mieux à l’attente de l’utilisateur (« ça se renouvelle fin février ») qu’une date qui saute occasionnellement d’un jour entier en avant.
Stocker anchorMonth avec anchorDay pour les cycles annuels — plutôt que de redériver le mois à partir d’une seule startDate qui pourrait elle-même être un jour bissextile — garde cela comme une simple consultation au lieu d’un cas particulier de plus dispersé dans le code.
Les essais sont un changement de cycle, pas un nouvel abonnement
Un essai gratuit qui se convertit en abonnement payant n’est pas un nouvel abonnement avec son propre ancrage — c’est le même abonnement dont le premier prélèvement payant fixe le jour d’ancrage. Le modéliser comme deux lignes séparées a été la première conception qui s’est effondrée pendant les tests : elle doublait les rappels de renouvellement et perdait le lien entre « l’essai a commencé » et « la carte est débitée ». Subly conserve à la place une seule ligne et un champ trialEndsAt ; la logique de rappel vérifie d’abord ce champ et ne retombe sur le calcul récurrent de nextChargeDate qu’une fois l’essai réellement converti.
Les rappels se planifient à partir de la date calculée, pas d’un minuteur
Comme nextChargeDate est une fonction pure des données stockées, Subly n’a jamais besoin d’une tâche de fond qui compte à rebours. Une tâche WorkManager redérive une fois par jour les quelques prochaines dates de renouvellement pour tous les abonnements, les compare à ce qui est déjà planifié, et ne touche à la file de notifications que pour ce qui a changé. Si vous modifiez le jour de facturation d’un abonnement, le tout prochain passage quotidien produit un rappel corrigé — il n’y a pas de minuteur qui dérive à invalider, car rien n’a jamais été compté à rebours en premier lieu.
Pourquoi ça vaut la peine d’être bien fait
Rien de tout cela n’a besoin d’un serveur. C’est de l’arithmétique de dates pure sur une LocalDate, et cela s’exécute en une fraction de milliseconde par abonnement — le genre de chose facile à faire légèrement de travers sans jamais le remarquer, jusqu’à ce qu’un rappel de renouvellement se déclenche un jour trop tard pour quelqu’un qui paie annuellement à l’avance. Bien traiter les cas particuliers une bonne fois, dans une seule fonction, coûte moins cher que de déboguer des signalements d’utilisateurs à propos d’un abonnement qui « se renouvelle un jour différent chaque année ».
Subly est disponible sur l’App Store et Google Play si vous voulez voir les rappels du côté du destinataire.
// À lire aussi
D’autres notes du journal
Construire le calendrier d'observance d'OldSchool : transformer des prises en un jour fiable
Un calendrier d'observance mensuel ressemble à un simple point coloré par jour. Voici le modèle d'événements, les règles de statut journalier et le calcul de ponctualité, sur l'appareil.
Construire Mintly : garder un minuteur de focus précis quand Android veut tuer le processus
Un minuteur Pomodoro en cours d'exécution pose un problème de fiabilité plus difficile qu'un simple rappel ponctuel. Voici comment Mintly survit au mode Doze, à la mort du processus et à la dérive écran éteint grâce à un service au premier plan et une heure de fin en temps réel.
Construire Granyn : un suivi de budget sans connexion bancaire, en trois tables
Comment Granyn suit les dépenses dans plusieurs devises et détecte les factures récurrentes sans lier le moindre compte bancaire — le schéma Room derrière tout ça, et les compromis qu'il impose.