Construir Granyn: un control de presupuesto sin inicio de sesión bancario, en tres tablas
Cómo Granyn controla el gasto en varias divisas y detecta facturas recurrentes sin vincular una sola cuenta bancaria — el esquema Room detrás de todo, y las concesiones que exige.
Toda app de presupuesto termina pidiéndote que conectes tu banco. Granyn no lo hace, y esa única restricción — sin Plaid, sin agregador, sin acceso de lectura a tus cuentas — decidió casi todas las demás decisiones de ingeniería de la app. Aquí está el esquema que hay debajo, y lo que se pierde al rechazar ese atajo.
Tres tablas, no un feed bancario
Sin un feed de transacciones en vivo, Granyn no tiene nada a lo que reaccionar. Así que parte de la unidad más pequeña posible: un registro que tú escribiste, hace dos toques.
@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,
)
Ese es todo el núcleo. Sin una tabla budgets con un tope mensual que haya que configurar de antemano, sin un libro mayor que distinga entre transacciones “pendientes” y “confirmadas” — el vocabulario de un feed bancario que no aplica cuando quien introduce los datos es una persona. Todo lo que viene después (el gráfico circular, el total mensual, el aviso de “te has pasado en restaurantes”) es una consulta sobre entries, agrupada por categoryId y un rango de fechas. Mantener el modelo así de delgado es lo que hace posible el registro en dos toques: no hay ninguna máquina de estados que recorrer, solo una inserción.
El dinero como enteros, la divisa como columna
El importe se guarda como amountMinor: Long — céntimos o kuruş, nunca un Double. Las matemáticas de moneda en coma flotante son el tipo de error que no aparece en las pruebas y sí aparece en el total mensual de un usuario tres años después, descuadrado por un céntimo e indepurable sin registros perfectos. Usar enteros con una convención explícita de unidad menor elimina de raíz toda esa categoría de error.
El campo de divisa es la decisión más interesante. Granyn no hace conversión de divisas — no obtiene tipos de cambio, no normaliza todo a una “divisa base”, no intenta decirte tu patrimonio neto en una sola cifra si has introducido gastos tanto en TRY como en EUR. Cada divisa se sigue y se suma de forma independiente. Es una limitación real para quien reparte su gasto entre dos divisas en el día a día. También es honesto: un tipo de cambio guardado en caché localmente ya está desactualizado en el momento en que lo ves, y fingir lo contrario haría que las cifras parecieran más precisas de lo que son. Una app tranquila prefiere decir “esto es lo que gastaste, en la divisa en la que lo gastaste” antes que fabricar una falsa precisión.
Detectar facturas recurrentes sin vigilar tu cuenta
Esta fue la única función que parecía necesitar un feed bancario para funcionar — ¿cómo notas que una suscripción está a punto de cobrarse si no estás vigilando la cuenta a la que afecta?
La respuesta es recurringId. No se infiere de un patrón en tu historial de transacciones, porque no hay historial del que inferir nada hasta que se lo has dicho a la app una vez. En su lugar, cuando registras un gasto, Granyn hace una pregunta concreta: ¿esto se parece a una RecurringRule que ya conoce? Si el label y el amountMinor de una factura coinciden aproximadamente, ambos se enlazan y lastSeenAt avanza.
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,
))
}
}
Una vez que existe una regla, una tarea diaria de WorkManager comprueba si now - lastSeenAt se acerca a periodDays, y muestra un recordatorio antes de que venza la factura — no después de que ya haya salido silenciosamente de tu cuenta, porque precisamente no hay cuenta que vigilar. Es un truco más modesto que la detección automática, y le pide una cosa más al usuario: decirle a la app, una vez y en términos claros, qué es lo que se repite. A cambio, nada de tu banco tiene que salir de tu teléfono.
Lo que este compromiso cuesta de verdad
Quiero ser específico sobre la desventaja, porque “sin vinculación bancaria” es fácil de vender y real de vivir. Cada gasto que no se registra es invisible — no hay un feed de respaldo que corrija el café que olvidaste añadir. La precisión de la app está limitada por lo constante que seas al usarla, no por lo buenos que sean sus algoritmos. Por eso el registro rápido importó más que cualquier gráfico: una hoja de entrada de dos toques es una función de retención disfrazada de función de UX, porque todo el modelo se derrumba si registrar algo es lo bastante molesto como para saltárselo.
Si prefieres la alternativa — conectar tu banco y dejar que un servicio reconcilie el feed por ti — ese compromiso existe y muchas apps lo hacen bien. Granyn es para la versión de esa decisión en la que prefieres teclear tres dígitos antes que entregar acceso de lectura a tu cuenta corriente.
El esquema de arriba es lo bastante pequeño como para caber en tu cabeza, y esa era la idea. Granyn está disponible en Google Play si quieres ver a qué equivalen realmente tres tablas y ningún feed bancario.
// Lecturas relacionadas
Más del diario
Android local-first en 2026: SQLite, Room y mantener los datos del usuario en el dispositivo
Una guía de 2026 para construir apps Android local-first con Room y SQLite — diseño de esquemas, migraciones, WAL, exportaciones y cuándo (y cuándo no) añadir sincronización.
Construir Stocky: predecir qué se agota en tu cocina — en el propio dispositivo
Cómo Stocky convierte el escaneo de un código de barras y un poco de historial de uso en una lista de la compra que aparece antes de que te quedes sin nada — con todos los cálculos, y todos los datos, guardados en tu teléfono.
OCR con la privacidad primero en Android: cómo Subly lee facturas sin la nube
Una guía práctica de 2026 sobre OCR en el dispositivo en Android con ML Kit Text Recognition: la configuración, el código y los trucos de extracción de campos que mantienen los datos del usuario fuera de tus servidores.