本文へスキップ
すべての記事

Granynを作る: 銀行ログインなしの家計簿を、3つのテーブルで

Granynが銀行口座を一つも連携せずに、複数通貨での支出を追跡し、定期的な請求を検知する仕組み — その裏にあるRoomスキーマと、そこで生じるトレードオフ。

MFKAPPS 1 分で読めます

家計簿アプリは最終的に銀行口座の連携を求めてくる。Granynはそれをしない。そしてこのたった一つの制約 — Plaidなし、アグリゲーターなし、口座への読み取りアクセスなし — が、このアプリのほぼすべてのエンジニアリング判断を決めた。以下はその裏側にあるスキーマと、この近道を拒否することで失われるものについての話だ。

銀行フィードではなく、3つのテーブル

リアルタイムの取引フィードがなければ、Granynには反応する材料が何もない。だからこのアプリは可能な限り最小の単位から始まる — 2回のタップ前にあなたが入力した1件の記録だ。

@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テーブルもなければ、「保留中」と「確定済み」の取引を区別する台帳もない — これらは、データを入力するのが人間である場合には当てはまらない、銀行フィードの語彙にすぎない。この先にあるもの(円グラフ、月次合計、「外食で予算オーバーです」という通知)はすべて、categoryIdと日付範囲でグループ化されたentriesへのクエリだ。モデルをこれほど薄く保つことが、2タップでの入力を可能にしている理由だ — たどるべき状態machineは存在せず、あるのは1回の挿入だけだ。

整数としてのお金、カラムとしての通貨

金額はamountMinor: Longとして保存される — セントやクルシュであり、決してDoubleではない。浮動小数点による通貨計算は、テストでは表面化せず、3年後にユーザーの月次合計が1セントずれて現れ、完璧なログなしにはデバッグできないという類のバグだ。明示的な最小単位の規約を持つ整数を使うことで、このバグのカテゴリ全体を根本から排除できる。

通貨フィールドはより興味深い判断だ。Granynは為替換算を行わない — レートを取得せず、すべてを「基準通貨」に正規化せず、TRYとEURの両方で支出を入力していたとしても、純資産を単一の数字で伝えようとはしない。各通貨は独立して追跡・合計される。日々2つの通貨にまたがって支出する人にとって、これは実際の制約だ。しかしそれは同時に誠実でもある — ローカルにキャッシュされた為替レートは、目にした瞬間にはすでに古くなっており、そうではないふりをすることは、数字を実際よりも正確に見せかけることになる。落ち着いたアプリは、偽の精密さを作り出すよりも「あなたが使った通貨で、あなたが使った金額はこれです」と言うことを選ぶ。

口座を監視せずに定期的な請求を捉える

これは、機能するために銀行フィードが必要に思えた唯一の機能だった — 口座を監視していないのに、サブスクリプションが引き落とされようとしていることをどうやって察知するのか?

答えはrecurringIdだ。これは取引履歴のパターンから推測されるものではない。なぜなら、一度アプリに伝えるまでは推測すべき履歴自体が存在しないからだ。代わりに、支出を記録するとき、Granynは狭い質問を投げかける — これは既知のRecurringRuleに似ているか? ある請求のlabelamountMinorがおおよそ一致すれば、両者は結び付けられ、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 - lastSeenAtperiodDaysに近づいているかを確認し、請求が来る前にリマインダーを表示する — 監視すべき口座がそもそも存在しないので、静かに口座から引き落とされた後ではなく。これは自動検知よりも控えめな仕掛けであり、ユーザーに一つだけ余分なことを求める — 何が繰り返されるのかを、一度だけ、平易な言葉でアプリに教えることだ。その代わり、銀行に関する情報が電話の外に出る必要は一切ない。

このトレードオフが実際に払う代償

このデメリットについては具体的に述べておきたい。「銀行連携なし」は売り込みやすいが、実際に付き合うのは大変なことだからだ。記録されなかった支出はすべて見えなくなる — 入れ忘れたコーヒー代を補正してくれる予備のフィードは存在しない。アプリの精度は、アルゴリズムの良し悪しではなく、どれだけ継続的にタップしたかによって決まる。どんなグラフよりもクイックキャプチャが重要だった理由はここにある — 2タップの入力画面は、UX機能を装った継続率のための機能なのだ。記録することが煩わしすぎてスキップされてしまえば、モデル全体が崩れてしまうから。

代わりの道を望むなら — 銀行を連携し、どこかのサービスにフィードの照合を任せる — そのトレードオフも存在し、それをうまくやっているアプリも多い。Granynは、当座預金口座への読み取りアクセスを渡すよりも、3桁の数字を入力するほうを選びたい、というその決断のバージョンのために存在している。

上のスキーマは頭の中に収まるくらい小さい。それがそもそもの狙いだった。3つのテーブルと銀行フィードなしが実際に何をもたらすのか見てみたいなら、GranynはGoogle Playで公開中だ。