Aller au contenu
Tous les articles

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.

MFKAPPS 5 min de lecture

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.