सामग्री पर जाएं
सभी पोस्ट

Subly बनाना: सब्सक्रिप्शन रिन्यूअल तारीखों के पीछे का कैलेंडर गणित

किसी सब्सक्रिप्शन की अगली चार्ज तारीख का अनुमान लगाना तब तक साधारण लगता है जब तक आप महीने के अंत की बिलिंग, लीप ईयर और ट्रायल कन्वर्शन से नहीं टकराते। यहाँ बताया गया है कि Subly इसे डिवाइस पर ही कैसे सही करता है।

MFKAPPS 5 मिनट पढ़ना

“यह कब रिन्यू होगा?” एक गणना नहीं, बल्कि एक साधारण लुकअप जैसा लगता है। पर ऐसा नहीं है। जिस पल कोई सब्सक्रिप्शन महीने में एक बार बिल होती है और 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 पर उपलब्ध है

// संबंधित पठन

जर्नल से और भी

MFKAPPS 6 मिनट पढ़ना

2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना

Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।

#android #engineering #room
MFKAPPS 5 मिनट पढ़ना

Kotlin, Room और Compose के लिए R8 और ProGuard: वह क्रैश जो सिर्फ़ रिलीज़ में होता है

एक Room + Compose Android ऐप डिबग में बिल्कुल ठीक क्यों चलता है और प्रोडक्शन में क्रैश क्यों होता है, और वे ख़ास R8/ProGuard keep नियम जो इसे यूज़र से पहले पकड़ लेते हैं।

#android #engineering #kotlin
MFKAPPS 5 मिनट पढ़ना

2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना

Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।

#android #engineering #room