Building Granyn: a budget tracker with no bank login, in three tables
How Granyn tracks spending across currencies and catches recurring bills without linking a single bank account — the Room schema and the trade-offs behind it.
Every budgeting app eventually asks you to connect your bank. Granyn doesn’t, and that single constraint — no Plaid, no aggregator, no read access to your accounts — decided almost every other engineering choice in the app. Here’s the schema underneath it, and what you give up by refusing the shortcut.
Three tables, not a bank feed
Without a live feed of transactions, Granyn has nothing to react to. So it starts from the smallest possible unit: an entry you typed in, two taps ago.
@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,
)
That’s the whole core. No budgets table with a monthly cap you have to configure up front, no ledger of “pending” versus “cleared” transactions — a bank feed’s vocabulary that doesn’t apply when a human is the one entering data. Everything downstream (the pie chart, the monthly total, the “you’re over on dining out” nudge) is a query over entries, grouped by categoryId and a date range. Keeping the model this thin is what makes two-tap entry possible: there’s no state machine to walk through, just an insert.
Money as integers, currency as a column
The amount is stored as amountMinor: Long — cents or kuruş, never a Double. Floating-point currency math is the kind of bug that doesn’t show up in testing and does show up in a user’s monthly total three years later, off by one cent, and undebuggable without perfect logs. Integers with an explicit minor-unit convention sidestep the entire category of bug.
The currency field is the more interesting decision. Granyn doesn’t do foreign-exchange conversion — it doesn’t fetch rates, doesn’t normalize everything to a “home currency,” doesn’t try to tell you your net worth in a single number if you’ve entered both TRY and EUR expenses. Each currency is tracked and summed independently. That’s a real limitation for someone splitting spending across two currencies day to day. It’s also honest: a locally cached FX rate is already stale the moment you see it, and pretending otherwise would make the numbers look more precise than they are. A calm app says “here’s what you spent, in the currency you spent it” rather than manufacturing false precision.
Catching recurring bills without watching your account
This was the one feature that felt like it needed a bank feed to work at all — how do you notice a subscription is about to hit if you’re not watching the account it hits?
The answer is recurringId. It isn’t inferred from a pattern in your transaction history, because there’s no transaction history to infer from until you’ve told the app about it once. Instead, when you log an entry, Granyn asks a narrow question: does this look like a RecurringRule you’ve already told it about? If a bill’s label and amountMinor roughly match, the two get linked, and lastSeenAt moves forward.
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,
))
}
}
Once a rule exists, a WorkManager job checks daily whether now - lastSeenAt is approaching periodDays, and surfaces a reminder before the bill is due — not after it’s already quietly left your account, because there’s no account to watch in the first place. It’s a smaller trick than automatic detection, and it asks one extra thing of the user: telling the app once, in plain terms, what recurs. In exchange, nothing about your bank ever has to leave your phone.
What the trade-off actually costs
I want to be specific about the downside, because “no bank linking” is an easy thing to market and a real thing to live with. Every entry that isn’t logged is invisible — there’s no fallback feed correcting for the coffee you forgot to add. The app’s accuracy is bounded by how consistently you tap it, not by how good its algorithms are. That’s why quick capture mattered more than any chart: a two-tap entry sheet is a retention feature disguised as a UX one, because the whole model falls apart if logging is annoying enough to skip.
If you want the alternative — connect your bank, and a service somewhere reconciles the feed for you — that trade-off exists and plenty of apps make it well. Granyn is for the version of that decision where you’d rather type three digits than hand over read access to your checking account.
The schema above is small enough to hold in your head, which was the point. Granyn is live on Google Play if you want to see what three tables and no bank feed actually add up to.
// Related reading
More from the journal
Local-first Android in 2026: SQLite, Room, and keeping user data on the device
A 2026 guide to building local-first Android apps with Room and SQLite — schema design, migrations, WAL, exports, and when (and when not) to add sync.
Building Stocky: predicting what your kitchen runs out of — on the device
How Stocky turns a barcode scan and a bit of usage history into a shopping list that shows up before you run out — with all the math, and all the data, staying on your phone.
Privacy-first OCR on Android: how Subly reads bills without the cloud
A practical 2026 guide to on-device OCR on Android with ML Kit Text Recognition — the setup, the code, and the field extraction tricks that keep user data off your servers.