Construire Granyn : un suivi de budget sans connexion bancaire, en trois tables
Comment Granyn suit les dépenses dans plusieurs devises et détecte les factures récurrentes sans lier le moindre compte bancaire — le schéma Room derrière tout ça, et les compromis qu'il impose.
Toute application de budget finit par vous demander de connecter votre banque. Granyn ne le fait pas, et cette seule contrainte — pas de Plaid, pas d’agrégateur, aucun accès en lecture à vos comptes — a déterminé presque tous les autres choix d’ingénierie de l’application. Voici le schéma qui se cache derrière, et ce que l’on perd en refusant ce raccourci.
Trois tables, pas un flux bancaire
Sans flux de transactions en direct, Granyn n’a rien à quoi réagir. Il part donc de la plus petite unité possible : une saisie que vous avez tapée, il y a deux gestes.
@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,
)
C’est tout le cœur du système. Pas de table budgets avec un plafond mensuel à configurer à l’avance, pas de grand livre distinguant transactions « en attente » et « validées » — le vocabulaire d’un flux bancaire qui ne s’applique pas quand c’est un humain qui saisit les données. Tout ce qui suit (le camembert, le total mensuel, l’alerte « vous dépassez sur les restaurants ») est une requête sur entries, groupée par categoryId et une plage de dates. Garder le modèle aussi mince est ce qui rend la saisie en deux gestes possible : il n’y a aucune machine à états à traverser, juste une insertion.
L’argent en entiers, la devise en colonne
Le montant est stocké en amountMinor: Long — centimes ou kuruş, jamais un Double. Les calculs monétaires en virgule flottante sont le genre de bug qui ne se voit pas en test et qui apparaît trois ans plus tard dans le total mensuel d’un utilisateur, décalé d’un centime, impossible à déboguer sans journaux parfaits. Utiliser des entiers avec une convention explicite d’unité mineure élimine toute cette catégorie de bug.
Le champ devise est la décision la plus intéressante. Granyn ne fait pas de conversion de change — il ne récupère pas de taux, ne normalise pas tout vers une « devise de référence », n’essaie pas de vous donner votre patrimoine net en un seul chiffre si vous avez saisi des dépenses en TRY et en EUR. Chaque devise est suivie et additionnée indépendamment. C’est une vraie limite pour quelqu’un qui répartit ses dépenses entre deux devises au quotidien. C’est aussi honnête : un taux de change mis en cache localement est déjà obsolète au moment où vous le voyez, et prétendre le contraire donnerait aux chiffres une précision qu’ils n’ont pas. Une application calme préfère dire « voici ce que vous avez dépensé, dans la devise où vous l’avez dépensé » plutôt que de fabriquer une fausse précision.
Détecter les factures récurrentes sans surveiller votre compte
C’est la seule fonctionnalité qui semblait avoir besoin d’un flux bancaire pour fonctionner du tout — comment remarquer qu’un abonnement est sur le point de tomber si vous ne surveillez pas le compte qu’il touche ?
La réponse s’appelle recurringId. Elle n’est pas déduite d’un motif dans votre historique de transactions, car il n’y a pas d’historique dont déduire quoi que ce soit tant que vous n’avez pas informé l’application une première fois. Au lieu de cela, quand vous saisissez une dépense, Granyn pose une question précise : est-ce que ça ressemble à une RecurringRule déjà connue ? Si le label et le amountMinor d’une facture correspondent à peu près, les deux sont liés, et lastSeenAt avance.
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,
))
}
}
Une fois une règle créée, une tâche WorkManager quotidienne vérifie si now - lastSeenAt s’approche de periodDays, et affiche un rappel avant l’échéance de la facture — pas après qu’elle ait déjà quitté discrètement votre compte, puisqu’il n’y a justement aucun compte à surveiller. C’est une astuce plus modeste que la détection automatique, et elle demande une chose de plus à l’utilisateur : dire une fois à l’application, en termes simples, ce qui se répète. En échange, rien concernant votre banque n’a besoin de quitter votre téléphone.
Ce que ce compromis coûte vraiment
Je veux être précis sur l’inconvénient, parce que « pas de connexion bancaire » est facile à vendre et réel à vivre. Chaque dépense non saisie est invisible — il n’y a pas de flux de secours qui corrige le café que vous avez oublié d’ajouter. La précision de l’application est limitée par la régularité avec laquelle vous la remplissez, pas par la qualité de ses algorithmes. C’est pourquoi la saisie rapide comptait plus que n’importe quel graphique : un écran de saisie en deux gestes est une fonctionnalité de rétention déguisée en fonctionnalité d’expérience utilisateur, parce que tout le modèle s’effondre si saisir une dépense est assez pénible pour être évité.
Si vous préférez l’alternative — connecter votre banque, et laisser un service quelque part réconcilier le flux pour vous — ce compromis existe et beaucoup d’applications le font bien. Granyn est fait pour la version de ce choix où vous préférez taper trois chiffres plutôt que céder un accès en lecture à votre compte courant.
Le schéma ci-dessus tient dans la tête, et c’était le but. Granyn est disponible sur Google Play si vous voulez voir à quoi trois tables et l’absence de flux bancaire aboutissent réellement.
// À lire aussi
D’autres notes du journal
Android local-first en 2026 : SQLite, Room et garder les données de l'utilisateur sur l'appareil
Un guide 2026 pour construire des applications Android local-first avec Room et SQLite — conception du schéma, migrations, WAL, exports, et quand (et quand ne pas) ajouter la synchronisation.
Construire Stocky : prédire ce qui va manquer dans votre cuisine — sur l'appareil lui-même
Comment Stocky transforme un scan de code-barres et un peu d'historique d'utilisation en une liste de courses qui apparaît avant que vous ne tombiez à court — avec tous les calculs, et toutes les données, qui restent sur votre téléphone.
OCR respectueux de la vie privée sur Android : comment Subly lit les factures sans le cloud
Un guide pratique 2026 de l'OCR sur l'appareil sous Android avec ML Kit Text Recognition — la configuration, le code et les astuces d'extraction de champs qui gardent les données utilisateur hors de vos serveurs.