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

Granyn बनाना: बिना बैंक लॉगिन वाला बजट ट्रैकर, बस तीन टेबल में

Granyn एक भी बैंक अकाउंट जोड़े बिना कई मुद्राओं में खर्च कैसे ट्रैक करता है और आवर्ती बिलों को कैसे पकड़ता है — इसके पीछे का Room स्कीमा, और इससे जुड़े समझौते।

MFKAPPS 5 मिनट पढ़ना

हर बजट ऐप देर-सवेर आपसे अपना बैंक कनेक्ट करने को कहता है। Granyn ऐसा नहीं करता, और इस एक अकेली शर्त ने — कोई Plaid नहीं, कोई एग्रीगेटर नहीं, आपके अकाउंट्स तक कोई रीड-एक्सेस नहीं — ऐप में लगभग हर दूसरे इंजीनियरिंग फैसले को तय किया। यहाँ इसके पीछे का स्कीमा है, और यह शॉर्टकट न लेने पर आप वास्तव में क्या खो देते हैं।

बैंक फ़ीड नहीं, तीन टेबल

बिना किसी लाइव ट्रांज़ैक्शन फ़ीड के, Granyn के पास प्रतिक्रिया देने के लिए कुछ भी नहीं है। तो यह सबसे छोटी संभव इकाई से शुरू होता है: एक एंट्री जो आपने दो टैप पहले टाइप की थी।

@Entity(tableName = "entries")
data class Entry(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val amountMinor: Long,      // stored in minor units — cents, kuruş
    val currency: String,       // ISO 4217, e.g. "TRY"
    val categoryId: Long,
    val note: String? = null,
    val at: Long,               // epoch millis
    val recurringId: Long? = null,
)

@Entity(tableName = "categories")
data class Category(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val icon: String,
)

@Entity(tableName = "recurring_rules")
data class RecurringRule(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val label: String,
    val amountMinor: Long,
    val currency: String,
    val categoryId: Long,
    val periodDays: Int,        // 30, 7, 365 — whatever the bill actually does
    val lastSeenAt: Long,
)

बस यही पूरा कोर है। कोई budgets टेबल नहीं जिसमें पहले से एक मासिक सीमा सेट करनी पड़े, कोई लेजर नहीं जो “पेंडिंग” और “क्लियर्ड” ट्रांज़ैक्शन को अलग करे — यह एक बैंक फ़ीड की शब्दावली है जो तब लागू नहीं होती जब डेटा दर्ज करने वाला एक इंसान हो। इसके बाद जो कुछ भी आता है (पाई चार्ट, मासिक कुल, “आप बाहर खाने में बजट से ऊपर जा रहे हैं” वाला नज दे) वह categoryId और एक तारीख़ सीमा के हिसाब से समूहित entries पर एक क्वेरी भर है। मॉडल को इतना पतला रखना ही दो-टैप एंट्री को संभव बनाता है: यहाँ गुज़रने के लिए कोई स्टेट मशीन नहीं है, बस एक इंसर्ट है।

पैसा पूर्णांकों में, मुद्रा एक कॉलम में

राशि amountMinor: Long के रूप में सहेजी जाती है — सेंट या कुरुश में, कभी Double में नहीं। फ़्लोटिंग-पॉइंट करेंसी गणित उस तरह का बग है जो टेस्टिंग में सामने नहीं आता और तीन साल बाद किसी यूज़र के मासिक टोटल में एक सेंट की गड़बड़ी के रूप में दिखता है, जिसे बिना परफ़ेक्ट लॉग के डीबग नहीं किया जा सकता। एक स्पष्ट माइनर-यूनिट कन्वेंशन के साथ पूर्णांकों का उपयोग इस पूरी बग-श्रेणी को शुरू से ही हटा देता है।

करेंसी फ़ील्ड ज़्यादा दिलचस्प फ़ैसला है। Granyn कोई फ़ॉरेन-एक्सचेंज कन्वर्ज़न नहीं करता — यह रेट्स नहीं लाता, सब कुछ किसी “होम करेंसी” में नॉर्मलाइज़ नहीं करता, और अगर आपने TRY और EUR दोनों में खर्च दर्ज किए हैं तो आपकी नेट वर्थ को एक ही आँकड़े में बताने की कोशिश नहीं करता। हर करेंसी स्वतंत्र रूप से ट्रैक और जोड़ी जाती है। दिन-प्रतिदिन दो करेंसियों में खर्च बाँटने वाले किसी व्यक्ति के लिए यह एक वास्तविक सीमा है। यह ईमानदार भी है: लोकली कैश की गई एक्सचेंज रेट देखते ही पुरानी हो चुकी होती है, और इसके उलट दिखावा करने से आँकड़े असल से ज़्यादा सटीक दिखने लगेंगे। एक शांत ऐप झूठी सटीकता गढ़ने के बजाय यह कहना पसंद करता है कि “आपने जो खर्च किया, उसी करेंसी में, यह रहा।“

अपने अकाउंट को देखे बिना आवर्ती बिल पकड़ना

यह वह इकलौता फ़ीचर था जो काम करने के लिए बैंक फ़ीड की ज़रूरत महसूस कराता था — अगर आप किसी अकाउंट को देख ही नहीं रहे, तो यह कैसे पता चलेगा कि कोई सब्सक्रिप्शन कटने वाला है?

जवाब है recurringId। यह आपके ट्रांज़ैक्शन हिस्ट्री के किसी पैटर्न से नहीं निकाला जाता, क्योंकि जब तक आप ऐप को एक बार बताते नहीं, तब तक कोई ऐसा हिस्ट्री ही नहीं है जिससे कुछ निकाला जा सके। इसके बजाय, जब आप कोई एंट्री दर्ज करते हैं, तो Granyn एक सीमित सवाल पूछता है: क्या यह किसी पहले से बताए गए RecurringRule जैसा दिखता है? अगर किसी बिल का label और amountMinor लगभग मेल खाते हैं, तो दोनों जुड़ जाते हैं, और lastSeenAt आगे बढ़ जाता है।

suspend fun matchOrCreateRule(entry: Entry, label: String): Long {
    val existing = ruleDao.findByLabel(label)
    return if (existing != null && amountsClose(existing.amountMinor, entry.amountMinor)) {
        ruleDao.updateLastSeen(existing.id, entry.at)
        existing.id
    } else {
        ruleDao.insert(RecurringRule(
            label = label,
            amountMinor = entry.amountMinor,
            currency = entry.currency,
            categoryId = entry.categoryId,
            periodDays = 30,
            lastSeenAt = entry.at,
        ))
    }
}

एक बार कोई रूल बन जाए, तो रोज़ चलने वाला एक WorkManager जॉब यह जाँचता है कि now - lastSeenAt, periodDays के करीब पहुँच रहा है या नहीं, और बिल की तारीख़ आने से पहले एक रिमाइंडर दिखाता है — इसके बाद नहीं कि वह चुपचाप आपके अकाउंट से निकल चुका हो, क्योंकि यहाँ देखने के लिए कोई अकाउंट है ही नहीं। यह ऑटोमैटिक डिटेक्शन से एक छोटी सी तरकीब है, और यह यूज़र से एक अतिरिक्त चीज़ माँगती है: एक बार, सीधे शब्दों में, ऐप को बता देना कि क्या दोहराया जाता है। बदले में, आपके बैंक से जुड़ी किसी भी चीज़ को आपके फ़ोन से बाहर जाने की ज़रूरत नहीं पड़ती।

यह समझौता असल में क्या कीमत वसूलता है

मैं इसके नुकसान के बारे में स्पष्ट रहना चाहता हूँ, क्योंकि “कोई बैंक लिंकिंग नहीं” बेचना आसान है, पर उसके साथ जीना असली बात है। जो भी खर्च दर्ज नहीं किया जाता, वह अदृश्य रहता है — कोई बैकअप फ़ीड नहीं जो उस कॉफ़ी को सुधार दे जिसे आप जोड़ना भूल गए। ऐप की सटीकता उसके एल्गोरिद्म कितने अच्छे हैं इससे नहीं, बल्कि आप उसे कितनी नियमितता से टैप करते हैं इससे तय होती है। यही वजह है कि क्विक कैप्चर किसी भी चार्ट से ज़्यादा मायने रखता था: दो-टैप वाली एंट्री शीट, UX फ़ीचर के भेस में एक रिटेंशन फ़ीचर है, क्योंकि अगर दर्ज करना इतना झंझट भरा हो कि छोड़ दिया जाए, तो पूरा मॉडल ही ढह जाता है।

अगर आप विकल्प चाहते हैं — अपना बैंक कनेक्ट करें, और कहीं कोई सर्विस आपके लिए फ़ीड को मिलाए — तो वह समझौता भी मौजूद है और कई ऐप इसे अच्छी तरह करते हैं। Granyn उस फ़ैसले के उस संस्करण के लिए है जहाँ आप अपने चेकिंग अकाउंट का रीड-एक्सेस देने के बजाय तीन अंक टाइप करना पसंद करते हैं।

ऊपर का स्कीमा इतना छोटा है कि आपके दिमाग़ में समा जाए — यही तो मक़सद था। अगर आप देखना चाहते हैं कि तीन टेबल और कोई बैंक फ़ीड न होना असल में किस चीज़ में तब्दील होता है, तो Granyn Google Play पर लाइव है।

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

जर्नल से और भी

MFKAPPS 8 मिनट पढ़ना

2026 में लोकल-फर्स्ट Android: SQLite, Room, और यूज़र डेटा को डिवाइस पर ही रखना

Room और SQLite के साथ लोकल-फर्स्ट Android ऐप बनाने की 2026 गाइड — स्कीमा डिज़ाइन, माइग्रेशन, WAL, एक्सपोर्ट, और सिंक कब जोड़ें (और कब नहीं)।

#android #engineering #privacy
MFKAPPS 4 मिनट पढ़ना

Stocky बनाना: आपकी रसोई में क्या खत्म होने वाला है, इसका पूर्वानुमान — वह भी डिवाइस पर ही

Stocky एक बारकोड स्कैन और थोड़े-से उपयोग इतिहास को ऐसी शॉपिंग लिस्ट में कैसे बदल देता है जो आपके खत्म होने से पहले ही सामने आ जाती है — और सारा गणित, सारा डेटा, आपके फोन पर ही रहता है।

#android #engineering #privacy
MFKAPPS 8 मिनट पढ़ना

Android पर प्राइवेसी-फर्स्ट OCR: Subly बिना क्लाउड के बिल कैसे पढ़ता है

2026 के लिए Android पर ऑन-डिवाइस OCR की व्यावहारिक गाइड, ML Kit Text Recognition के साथ — सेटअप, कोड, और वे फ़ील्ड-एक्सट्रैक्शन तरकीबें जो यूज़र के डेटा को आपके सर्वर से दूर रखती हैं।

#android #engineering #privacy