İçeriğe geç
Tüm yazılar

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.

MFKAPPS 4 dk okuma

Her bütçe uygulaması er ya da geç bankanıza bağlanmanızı ister. Granyn istemiyor ve bu tek kısıtlama — Plaid yok, aracı kurum yok, hesaplarınıza okuma erişimi yok — uygulamadaki hemen hemen her diğer mühendislik kararını belirledi. İşte altındaki şema ve bu kestirmeden vazgeçince neyden vazgeçtiğiniz.

Banka akışı değil, üç tablo

Canlı bir işlem akışı olmadan, Granyn’in tepki verecek hiçbir şeyi yok. Bu yüzden mümkün olan en küçük birimden başlıyor: iki dokunuş önce sizin yazdığınız bir kayıt.

@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,
)

Çekirdeğin tamamı bu kadar. Önceden ayarlamanız gereken aylık bir tavanı olan bir budgets tablosu yok; “bekleyen” ile “kesinleşmiş” işlemleri ayıran bir defter yok — bunlar, veriyi giren kişi bir insan olduğunda karşılığı olmayan bir banka akışının kelime dağarcığı. Aşağı akıştaki her şey (pasta grafiği, aylık toplam, “dışarıda yemekte bütçeni aştın” uyarısı) categoryId ve bir tarih aralığına göre gruplanan entries üzerinde bir sorgudur. Modeli bu kadar ince tutmak, iki dokunuşla kayıt eklemeyi mümkün kılan şey: içinden geçilecek bir durum makinesi yok, sadece bir ekleme var.

Sayı olarak para, sütun olarak para birimi

Tutar amountMinor: Long olarak saklanır — kuruş ya da sent, asla Double değil. Kayan noktalı para matematiği, testte ortaya çıkmayan ve bir kullanıcının aylık toplamında üç yıl sonra, bir kuruş sapmayla ve mükemmel loglar olmadan hata ayıklanamayacak şekilde ortaya çıkan türden bir hatadır. Açık bir alt birim kuralıyla tamsayı kullanmak, bu hata kategorisinin tamamını baştan eler.

Para birimi alanı daha ilginç bir karar. Granyn döviz dönüşümü yapmaz — kur çekmez, her şeyi bir “ana para birimine” normalize etmez, hem TRY hem EUR harcaması girdiyseniz net varlığınızı tek bir sayıyla söylemeye çalışmaz. Her para birimi bağımsız olarak takip edilir ve toplanır. Gün içinde iki para birimi arasında harcama yapan biri için bu gerçek bir sınırlama. Ama aynı zamanda dürüst: yerelde önbelleğe alınmış bir döviz kuru, gördüğünüz an zaten eskimiştir ve aksini iddia etmek, sayıların olduklarından daha kesin görünmesini sağlar. Sakin bir uygulama, sahte bir kesinlik uydurmak yerine “harcadığın parayı, harcadığın para biriminde işte burada” der.

Hesabınızı izlemeden tekrarlayan faturaları yakalamak

Bu, çalışması için bir banka akışına ihtiyaç duyuyormuş gibi hissettiren tek özellikti — bir aboneliğin hesabınıza düşmek üzere olduğunu, o hesabı izlemiyorsanız nasıl fark edersiniz?

Cevap recurringId. İşlem geçmişinizdeki bir örüntüden çıkarılmıyor, çünkü uygulamaya bir kez söylemediğiniz sürece çıkarım yapacak bir işlem geçmişi yok. Bunun yerine, bir kayıt eklediğinizde Granyn dar bir soru sorar: bu, daha önce uygulamaya söylediğiniz bir RecurringRule’a benziyor mu? Bir faturanın label’ı ve amountMinor’u kabaca eşleşiyorsa, ikisi birbirine bağlanır ve lastSeenAt ileri gider.

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,
        ))
    }
}

Bir kural oluştuğunda, günlük çalışan bir WorkManager işi now - lastSeenAt’in periodDays’e yaklaşıp yaklaşmadığını kontrol eder ve fatura vadesi gelmeden önce bir hatırlatma gösterir — zaten izlenecek bir hesap olmadığı için, fatura sessizce hesabınızdan çıktıktan sonra değil. Bu, otomatik tespitten daha küçük bir hile ve kullanıcıdan bir şey daha istiyor: neyin tekrarlandığını uygulamaya bir kez, açık bir şekilde söylemek. Karşılığında, bankanızla ilgili hiçbir şeyin telefonunuzdan çıkması gerekmiyor.

Bu ödünleşimin gerçek maliyeti

Bunun olumsuz tarafı hakkında net olmak istiyorum, çünkü “banka bağlantısı yok” pazarlaması kolay, yaşaması gerçek bir şey. Kaydedilmeyen her harcama görünmezdir — eklemeyi unuttuğunuz kahveyi düzelten yedek bir akış yok. Uygulamanın doğruluğu, algoritmalarının ne kadar iyi olduğuyla değil, onu ne kadar tutarlı dokunduğunuzla sınırlıdır. Herhangi bir grafikten daha çok hızlı kayıt önemliydi çünkü kayıt atlanacak kadar sıkıcıysa tüm model çöker — bu yüzden iki dokunuşlu kayıt ekranı, UX özelliği kılığına girmiş bir elde tutma özelliğidir.

Alternatifi istiyorsanız — bankanızı bağlayın, bir yerlerdeki bir servis akışı sizin için uzlaştırsın — bu ödünleşim var ve birçok uygulama bunu iyi yapıyor. Granyn, üç haneli bir sayı yazmayı, vadesiz hesabınıza okuma erişimi vermeye tercih ettiğiniz kararın versiyonu için var.

Yukarıdaki şema, kafanızda tutabileceğiniz kadar küçük — mesele de buydu. Üç tablonun ve banka akışı olmamasının gerçekte neye denk geldiğini görmek isterseniz Granyn Google Play’de yayında.