~/austinmula
Mobile transaction tracker
Mobile ApplicationLive

M-Pesa Transaction Tracker

An Android app that turns Kenya's M-PESA confirmation SMS into a real ledger: spend by category, daily trends, and Fuliza exposure. Kotlin, Jetpack Compose, and Room, built around a framework-free chain of 21 parser handlers that are pure functions of text — so every message format is unit-tested on the JVM without an emulator.

Problem

M-PESA is the default way money moves in Kenya, but Safaricom gives you no spending history - only a stream of confirmation SMS in your inbox. Statements arrive as locked PDFs, and lag behind. So most users havedetailed records of every shilling they spend and no way to see where it went. The goal was an Android app that turns that SMS inbox into a real ledger by parsing each message into a typed transaction, storing it locally, and showing totals, spend-by-category, daily trends and Fuliza exposure.

Stack

Kotlin, Jetpack Compose + Material3 (single activity), Room for local persistence with KSP codegen, WorkManager for background ingestion, DataStore for settings, coroutines/Flow throughout. Min SDK 24, target 36. JUnit for tests, all pure JVM, no emulator required. Dependencies are wired by a hand-rolled service locator rather than Hilt, which keeps the build simple at this size.

Key decisions

The parser is a framework-free plugin chain. 21 handlers (send, paybill, till, withdrawal, M-Shwari, KCB M-PESA, Fuliza, reversals, failures…) each implement matches()/parse(), and an ordered registry returns the first match specific formats before catch-alls. No Context, no Room, no coroutines in that layer, so every format is a pure function of text and testable in milliseconds.

Three outcomes, not two. A message is either a financial transaction, a non-financial one (failed/info) stored with isTransaction = false so it's auditable but excluded from totals, or null — an unrecognized format, which is logged to an unparsed_messages table and surfaced in the UI. That table became the feedback loop: real unparsed logs from my own phone drove several rounds of new parsers.

Offline by construction. The app declares no INTERNET permission at all. Bank SMS never touches a network, privacy is enforced by the manifest, not by policy.


Dedupe on the M-PESA transaction code via a unique Room index, so the live SMS receiver and the bulk inbox importer can overlap freely; ingestion returns Inserted/Duplicate/Unparsed and the counts feed the import summary.

Receiver hands off to WorkManager instead of doing work inline, so ingestion survives process death and Doze.

Own-account transfers are flagged (isOwnAccountTransfer) so moving money to M-Shwari doesn't read as spending, and Fuliza outstanding is computed as a running balance rather than a naive drawn-minus-repaid sum.