Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Рано или поздно любое приложение для бюджета просит подключить ваш банк. Granyn этого не делает, и одно это ограничение — никакого Plaid, никакого агрегатора, никакого доступа на чтение к вашим счетам — определило почти все остальные инженерные решения в приложении. Вот схема, которая стоит за этим, и то, чем приходится жертвовать, отказываясь от этого пути напрямую.
Три таблицы, а не банковский фид
Без живого потока транзакций Granyn попросту не на что реагировать. Поэтому всё начинается с наименьшей возможной единицы: записи, которую вы сами ввели два касания назад.
@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,
)
Это и есть всё ядро. Нет таблицы budgets с месячным лимитом, который нужно было бы настроить заранее, нет журнала, различающего «ожидающие» и «подтверждённые» транзакции — это словарь банковского фида, который не имеет смысла, когда данные вводит человек. Всё, что дальше (круговая диаграмма, месячный итог, напоминание «вы превысили бюджет на рестораны»), — это просто запрос к entries, сгруппированный по categoryId и диапазону дат. Именно то, что модель остаётся такой тонкой, делает возможным ввод в два касания: здесь нет никакого автомата состояний, через который нужно пройти, — есть только вставка записи.
Деньги как целые числа, валюта как отдельное поле
Сумма хранится как amountMinor: Long — центы или куруши, никогда не Double. Денежная арифметика с плавающей точкой — это тот тип бага, который не проявляется на тестах и всплывает три года спустя в месячном итоге пользователя, разойдясь на один цент, и его невозможно отладить без идеальных логов. Использование целых чисел с явным соглашением о минимальной единице исключает всю эту категорию ошибок в принципе.
Поле валюты — решение более интересное. Granyn не занимается конвертацией валют — он не получает курсы, не приводит всё к какой-то «домашней валюте», не пытается сообщить вам чистый капитал одной цифрой, если вы вносили расходы и в TRY, и в EUR. Каждая валюта отслеживается и суммируется независимо. Для того, кто изо дня в день распределяет траты между двумя валютами, это реальное ограничение. Но это и честность: локально закешированный курс обмена устаревает в тот же момент, как вы его увидели, и притворяться иначе означало бы заставить цифры казаться точнее, чем они есть. Спокойное приложение предпочитает сказать «вот что вы потратили, в той валюте, в которой потратили», а не изобретать ложную точность.
Как замечать повторяющиеся счета, не наблюдая за счётом в банке
Это была единственная функция, для работы которой, казалось, действительно нужен банковский фид — как заметить, что подписка вот-вот спишется, если вы не следите за счётом, с которого она списывается?
Ответ — recurringId. Он не выводится из паттерна в истории ваших транзакций, потому что никакой истории, из которой можно было бы что-то вывести, попросту нет, пока вы сами не сообщили приложению об этом хотя бы раз. Вместо этого, когда вы вносите запись, Granyn задаёт узкий вопрос: похоже ли это на уже известное RecurringRule? Если label и amountMinor счёта примерно совпадают, они связываются друг с другом, и lastSeenAt сдвигается вперёд.
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,
))
}
}
Как только правило создано, ежедневная задача WorkManager проверяет, приближается ли now - lastSeenAt к periodDays, и показывает напоминание до того, как счёт наступит — а не после того, как он уже тихо списался со счёта, ведь наблюдать попросту не за чем. Это более скромный трюк, чем автоматическое обнаружение, и он требует от пользователя одной лишней вещи: один раз, простыми словами, сказать приложению, что именно повторяется. Взамен ничему из вашего банка не нужно покидать телефон.
Чего этот компромисс стоит на самом деле
Хочу быть конкретным насчёт минуса, потому что «без привязки банка» легко продать, но по-настоящему приходится с этим жить. Каждая незанесённая трата невидима — нет резервного фида, который исправил бы кофе, который вы забыли добавить. Точность приложения ограничена не тем, насколько хороши его алгоритмы, а тем, насколько последовательно вы им пользуетесь. Именно поэтому быстрый ввод значил больше, чем любой график: экран ввода в два касания — это функция удержания пользователя, замаскированная под UX-функцию, потому что вся модель рушится, если вносить запись достаточно неудобно, чтобы это пропускать.
Если вам ближе альтернатива — подключить банк и позволить какому-то сервису сверять фид за вас, — такой компромисс тоже существует, и многие приложения делают это хорошо. Granyn создан для той версии этого выбора, где вы предпочитаете набрать три цифры, а не отдавать доступ на чтение к своему расчётному счёту.
Схема выше настолько мала, что помещается в голове, — в этом и была цель. Granyn уже доступен в Google Play, если хотите увидеть, во что на самом деле выливаются три таблицы и отсутствие банковского фида.
// По теме
Ещё из журнала
Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.
Как я делал Stocky: предсказать, что заканчивается на вашей кухне — прямо на устройстве
Как Stocky превращает скан штрихкода и немного истории использования в список покупок, который появляется прежде, чем у вас что-то закончится — и вся математика, и все данные остаются на вашем телефоне.
OCR с приоритетом приватности на Android: как Subly читает счета без облака
Практическое руководство 2026 года по OCR на устройстве под Android с ML Kit Text Recognition — настройка, код и приёмы извлечения полей, которые держат данные пользователей вне ваших серверов.