Subly बनाना: सब्सक्रिप्शन रिन्यूअल तारीखों के पीछे का कैलेंडर गणित
किसी सब्सक्रिप्शन की अगली चार्ज तारीख का अनुमान लगाना तब तक साधारण लगता है जब तक आप महीने के अंत की बिलिंग, लीप ईयर और ट्रायल कन्वर्शन से नहीं टकराते। यहाँ बताया गया है कि Subly इसे डिवाइस पर ही कैसे सही करता है।
“यह कब रिन्यू होगा?” एक गणना नहीं, बल्कि एक साधारण लुकअप जैसा लगता है। पर ऐसा नहीं है। जिस पल कोई सब्सक्रिप्शन महीने में एक बार बिल होती है और 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 जनवरी को शुरू हुई हो, उसके लिए यह चुपचाप टूट जाता है। Kotlin की डेट लाइब्रेरी में LocalDate.plusMonths पहले से ही 31 फ़रवरी को घटाकर 28 (या 29) कर देता है — जो उस बदलाव के लिए सही है, लेकिन बग अगले महीने सामने आता है। अगर आप plusMonths(1) को मूल बिलिंग दिन से नहीं बल्कि पिछली गणना की गई तारीख से जोड़ते रहते हैं, तो 31 तारीख को शुरू हुई सब्सक्रिप्शन स्थायी रूप से 28 की ओर खिसक जाती है और उन महीनों में भी कभी 31 पर वापस नहीं आती जिनमें 31 तारीख होती है।
असली बिलिंग सिस्टम खिसकते नहीं हैं। Netflix का फ़रवरी में आपको 28 तारीख को चार्ज करने के बाद 31 दिनों वाले महीने में 31 तारीख को चार्ज करना सामान्य और अपेक्षित व्यवहार है — यह कोई बग नहीं जिसे सिकुड़ी हुई तारीख याद रखकर “ठीक” किया जाए।
आख़िरी इनवॉइस पर नहीं, बिलिंग दिन पर टिकें
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 नॉन-लीप सालों में चुपचाप 1 मार्च पर कूदने के बजाय 28 फ़रवरी पर सीमित करता है — जो ज़्यादातर असली बिलिंग प्रोवाइडर जो करते हैं उसे दोहराता है, और उस तारीख से बेहतर उपयोगकर्ता की उम्मीद (“यह फ़रवरी के अंत में रिन्यू होता है”) से मेल खाता है जो कभी-कभार पूरा एक दिन आगे कूद जाती है।
सालाना साइकल के लिए anchorMonth को anchorDay के साथ सेव करना — बजाय इसके कि महीना एक अकेले startDate से दोबारा निकाला जाए जो ख़ुद एक लीप डे हो सकता है — इसे कोड में बिखरे एक और स्पेशल केस के बजाय एक सीधा लुकअप बनाए रखता है।
ट्रायल एक नई सब्सक्रिप्शन नहीं, बल्कि साइकल का बदलाव है
एक मुफ़्त ट्रायल का पेड में बदलना अपने खुद के एंकर वाली नई सब्सक्रिप्शन नहीं है — यह वही सब्सक्रिप्शन है जिसका पहला पेड चार्ज एंकर दिन तय करता है। इसे दो अलग-अलग रो के रूप में मॉडल करना वह पहला डिज़ाइन था जो टेस्टिंग में टूट गया: इसने रिन्यूअल रिमाइंडर दोगुने कर दिए और “ट्रायल शुरू हुआ” तथा “कार्ड चार्ज हुआ” के बीच का संबंध खो दिया। इसकी बजाय Subly एक ही रो और एक trialEndsAt फ़ील्ड रखता है; रिमाइंडर लॉजिक पहले इस फ़ील्ड को जांचता है और तभी नियमित nextChargeDate गणना पर वापस जाता है जब ट्रायल वास्तव में कन्वर्ट हो चुका हो।
रिमाइंडर टाइमर पर नहीं, गणना की गई तारीख पर शेड्यूल होते हैं
चूंकि nextChargeDate सेव किए गए डेटा का एक शुद्ध फ़ंक्शन है, Subly को कभी भी गिनती करने वाले बैकग्राउंड जॉब की ज़रूरत नहीं पड़ती। एक WorkManager टास्क दिन में एक बार सभी सब्सक्रिप्शन के लिए अगली कुछ रिन्यूअल तारीखें दोबारा निकालता है, उन्हें पहले से शेड्यूल की गई तारीखों से मिलाता है, और सिर्फ़ बदले हुए हिस्से के लिए नोटिफ़िकेशन क्यू को छूता है। अगर आप किसी सब्सक्रिप्शन का बिलिंग दिन बदलते हैं, तो अगला दैनिक पास तुरंत एक सही किया हुआ रिमाइंडर बना देता है — कोई खिसकता हुआ टाइमर नहीं जिसे अमान्य करना पड़े, क्योंकि शुरुआत में कुछ भी गिना ही नहीं जा रहा था।
इसे अच्छी तरह करने लायक क्यों है
इनमें से किसी को भी सर्वर की ज़रूरत नहीं। यह एक LocalDate पर शुद्ध तारीख अंकगणित है, और यह हर सब्सक्रिप्शन के लिए एक मिलीसेकंड के एक अंश में चलता है — ऐसी चीज़ जिसे थोड़ा ग़लत करना आसान है और कभी नोटिस भी नहीं होता, जब तक कि किसी ऐसे व्यक्ति के लिए जो सालाना पहले से भुगतान करता है, रिन्यूअल रिमाइंडर एक दिन देर से न बज जाए। एज केस को एक बार, एक ही फ़ंक्शन में सही करना, “हर साल अलग दिन रिन्यू होने वाली” सब्सक्रिप्शन के बारे में यूज़र रिपोर्ट्स डीबग करने से सस्ता पड़ता है।
अगर आप रिमाइंडर को पाने वाले पक्ष से देखना चाहते हैं, तो Subly App Store और Google Play पर उपलब्ध है।
// संबंधित पठन
जर्नल से और भी
OldSchool का एडहेरेंस कैलेंडर बनाना: दवा के टैप्स को भरोसेमंद दिन में बदलना
एक महीने का एडहेरेंस कैलेंडर हर दिन के लिए बस एक रंगीन डॉट जैसा दिखता है। जानिए इसके पीछे का इवेंट मॉडल, दिन के स्टेटस के नियम, और समय-पालन का गणित, वह भी डिवाइस पर।
Mintly बनाना: जब Android प्रोसेस को मारना चाहता है तब भी फोकस टाइमर को सटीक रखना
चल रहे Pomodoro टाइमर की विश्वसनीयता की समस्या एक बार वाले रिमाइंडर से ज़्यादा कठिन होती है। जानिए कैसे Mintly एक फोरग्राउंड सर्विस और वॉल-क्लॉक एंड टाइम की मदद से Doze, प्रोसेस डेथ और स्क्रीन-ऑफ ड्रिफ्ट से बचता है।
Granyn बनाना: बिना बैंक लॉगिन वाला बजट ट्रैकर, बस तीन टेबल में
Granyn एक भी बैंक अकाउंट जोड़े बिना कई मुद्राओं में खर्च कैसे ट्रैक करता है और आवर्ती बिलों को कैसे पकड़ता है — इसके पीछे का Room स्कीमा, और इससे जुड़े समझौते।