Subly'yi Geliştirmek: abonelik yenileme tarihlerinin arkasındaki takvim matematiği
Bir aboneliğin sonraki ödeme tarihini tahmin etmek, ay sonu faturalandırmaya, artık yıllara ve deneme sürümü dönüşümlerine çarpana kadar önemsiz görünür. Subly'nin bunu cihaz üzerinde nasıl doğru yaptığı burada.
“Bu ne zaman yenilenecek?” bir hesaplama değil, bir sorgu gibi görünür. Değildir. Bir abonelik aylık faturalandırılıyorsa ve 31’inde başlamışsa, ya da yıllık faturalandırılıyorsa ve 29 Şubat’ta başlamışsa, cevap artık bariz olmaktan çıkar — ve Subly’nin bunu her seferinde doğru yapması gerekir, çünkü yanlış bir yenileme hatırlatıcısı, hiç hatırlatıcı olmamasından daha kötüdür.
İşte Subly’nin “sonraki ödeme” tarihinin arkasında sessizce çalışan takvim matematiği ve onu göründüğünden daha zor kılan uç durumlar.
Naif sürüm ikinci ayda bozulur
İlk içgüdü, bir başlangıç tarihi saklayıp bir aralık eklemektir:
fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
when (cycle) {
BillingCycle.MONTHLY -> start.plusMonths(1)
BillingCycle.YEARLY -> start.plusYears(1)
BillingCycle.WEEKLY -> start.plusWeeks(1)
}
Bu, 10’unda başlayan bir abonelik için işe yarar. 31 Ocak’ta başlayan biri için sessizce bozulur. Kotlin’in tarih kütüphanesindeki LocalDate.plusMonths, 31 Şubat’ı zaten 28’e (ya da 29’a) indirger — bu o geçiş için doğrudur, ama hata bir sonraki ay ortaya çıkar. plusMonths(1)’i orijinal fatura gününden değil de önceki hesaplanan tarihten zincirlemeye devam ederseniz, 31’inde başlayan bir abonelik kalıcı olarak 28’e kayar ve 31 günü olan aylarda bile asla 31’ine geri dönmez.
Gerçek faturalandırma sistemleri kaymaz. Netflix’in, Şubat’ta sizi 28’inde faturalandırdıktan sonra 31 günlük bir ayda sizi 31’inde faturalandırması, normal ve beklenen bir davranıştır — küçülmüş tarihi hatırlayarak “düzeltilmesi” gereken bir hata değildir.
Son faturaya değil, fatura gününe sabitleyin
Subly, aboneliğin faturalandırılması gereken ayın gününü — çıpa gününü — hesaplanan tarihlerin çalışan geçmişinden ayrı olarak saklar:
@Entity(tableName = "subscriptions")
data class Subscription(
@PrimaryKey val id: String,
val cycle: BillingCycle,
val anchorDay: Int, // 1..31, "gerçek" fatura günü
val anchorMonth: Int?, // sadece YEARLY döngülerde kullanılır
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))
}
Her hesaplama çıpa gününden başlar ve yalnızca o ay için sınırlar. Şubat 28 (ya da 29) alır, Mart doğrudan 31’e geri döner. Hiçbir şey geçen ayın küçülmesini hatırlamaz. Test sırasında karşılaşılan her “Mart yenilemem neden 28’ini gösteriyor” vakasını çözen tek değişiklik budur.
Yıllık döngülerin de bir çıpa aya ihtiyacı var
Yıllık aboneliklerin kendi tuzağı vardır: 29 Şubat. Bir artık günde sabitlenmiş bir abonelik, o tarihin dört yılın üçünde var olmadığı durumlar için açık bir kurala ihtiyaç duyar. Subly, artık olmayan yıllarda sessizce 1 Mart’a atlamak yerine 28 Şubat’a sabitler — çoğu gerçek faturalandırma sağlayıcısının yaptığını yansıtır ve bazen bir tam gün ileri atlayan bir tarihten daha çok kullanıcı beklentisiyle (“Şubat sonunda yenileniyor”) örtüşür.
Yıllık döngüler için anchorMonth’u, ayı tek bir startDate’ten yeniden türetmek yerine anchorDay ile birlikte saklamak — ki bu tarihin kendisi bir artık gün olabilir — bunu kod içine dağılmış başka bir özel durum yerine basit bir sorgu olarak tutar.
Deneme süreleri yeni bir abonelik değil, bir döngü değişikliğidir
Ücretsiz bir denemenin ücretliye dönüşmesi, kendi çıpasına sahip yeni bir abonelik değildir — ilk ücretli ödemesi çıpa gününü belirleyen aynı aboneliktir. Bunu iki ayrı satır olarak modellemek, testte parçalanan ilk tasarımdı: yenileme hatırlatıcılarını ikiye katladı ve “deneme başladı” ile “karttan çekim yapıldı” arasındaki bağlantıyı kaybetti. Bunun yerine Subly tek bir satır ve bir trialEndsAt alanı tutar; hatırlatıcı mantığı önce bu alanı kontrol eder ve yalnızca deneme gerçekten dönüştükten sonra yinelenen nextChargeDate hesaplamasına döner.
Hatırlatıcılar bir zamanlayıcıya değil, hesaplanan tarihe göre planlanır
nextChargeDate saklanan verinin saf bir fonksiyonu olduğu için, Subly’nin geri sayım yapan bir arka plan işine hiç ihtiyacı yoktur. Bir WorkManager görevi günde bir kez tüm abonelikler için sonraki birkaç yenileme tarihini yeniden türetir, bunları zaten planlanmış olanla karşılaştırır ve yalnızca değişeni bildirim kuyruğunda günceller. Bir aboneliğin fatura gününü düzenlerseniz, bir sonraki günlük geçiş düzeltilmiş bir hatırlatıcı üretir — geçersiz kılınacak kayan bir zamanlayıcı yoktur, çünkü zaten hiçbir şey geri sayılmıyordu.
Bunu neden özenle yapmaya değer
Bunların hiçbiri bir sunucuya ihtiyaç duymaz. Bir LocalDate üzerinde saf tarih aritmetiğidir ve abonelik başına milisaniyenin bir kısmında çalışır — hafifçe yanlış yapılıp fark edilmesi kolay olan türden bir şey, ta ki yıllık peşin ödeme yapan biri için bir yenileme hatırlatıcısı bir gün geç ateşlenene kadar. Uç durumları bir kez, tek bir fonksiyonda doğru yapmak, “her yıl farklı bir günde yenilenen” bir abonelik hakkındaki kullanıcı raporlarını hata ayıklamaktan daha ucuzdur.
Hatırlatıcıları alıcı tarafta görmek isterseniz Subly App Store ve Google Play’de yayında.
// İlgili okumalar
Günlükten dahası
OldSchool'un düzen takvimini inşa etmek: doz dokunuşlarını güvenilir bir güne dönüştürmek
Aylık bir düzen takvimi, her gün için tek renkli bir nokta gibi görünür. İşte arkasındaki olay modeli, gün durumu kuralları ve zamanında alım matematiği, cihaz üzerinde.
Mintly'i inşa etmek: Android süreci öldürmek isterken bir odak zamanlayıcısını doğru tutmak
Çalışan bir Pomodoro zamanlayıcısının, tek seferlik bir hatırlatıcıdan daha zor bir güvenilirlik sorunu var. İşte Mintly'in ön plan servisi ve duvar saati bitiş zamanıyla Doze, süreç ölümü ve ekran kapalıyken oluşan kaymayı nasıl atlattığı.
Granyn'i Geliştirmek: banka girişi olmayan bir bütçe takipçisi, üç tabloda
Granyn tek bir banka hesabı bağlamadan, para birimleri arasında harcamaları nasıl takip ediyor ve tekrarlayan faturaları nasıl yakalıyor — altındaki Room şeması ve bunun getirdiği ödünleşimler.