An Android expense tracker that reads bank SMS messages on your phone and turns
them into a categorized, queryable ledger. Everything runs locally — there is no
server, no account, and the app does not declare INTERNET permission.
Built with React Native + Expo, expo-sqlite, and TypeScript.
Indian banks send a transactional SMS for every debit, credit, UPI transfer, and card swipe. Those SMS contain everything you'd want from a Mint-style tracker (amount, merchant, account, time) and they're already on your phone. The app reads them, parses them, dedupes them, learns your categories, and lets you slice the data — without leaving the device.
- SMS backfill — on first launch, reads the last 180 days of SMS from supported banks and populates the ledger.
- Foreground rescan — pull-to-refresh runs a 7-day rescan; the dedupe key prevents duplicates.
- Supported banks/streams
- HDFC UPI (
src/sms/parsers/hdfcUpi.ts) - HDFC Credit Card (
src/sms/parsers/hdfcCc.ts) - Axis Credit Card (
src/sms/parsers/axisCc.ts)
- HDFC UPI (
- Manual entry — a Cash account is seeded on first launch; the floating action button on the Transactions screen opens a manual-entry form.
- Smart categorization
- Built-in seed rules (Swiggy → Food, Uber → Transport, etc.).
- Learned rules: tagging a transaction's merchant once teaches the app for all future and (optionally) past transactions with the same merchant.
- Reports — month-over-month spend, by-category, and by-account breakdowns.
- Dedupe — transactions are keyed by
account + IST-day + amount + directionwith optional ±1-day fuzzy matching to handle settlement-day shifts. - IST-correct dates — all calendar-day math is done in Asia/Kolkata so "today" never drifts.
- Paise-precision money — amounts are stored as integer paise; no floats.
App.tsx # init: DB → seed categories → ensure Cash → register parsers
├── app/ # UI layer
│ ├── navigation/ # Stack + bottom tabs
│ ├── screens/ # Onboarding, Transactions, Detail, Add, Reports, Settings
│ ├── components/ # TransactionRow, CategoryPicker
│ ├── hooks/ # useTransactions, useForegroundRescan
│ └── theme.ts
└── src/ # Domain + persistence (platform-agnostic)
├── db/
│ ├── driver.ts # DbDriver interface
│ ├── driver.expo.ts # expo-sqlite implementation
│ ├── driver.node.ts # better-sqlite3 implementation (tests)
│ ├── schema.ts # SCHEMA_V1 + MIGRATIONS
│ ├── migrations.ts
│ ├── accounts.ts # ensureCashAccount, account lookup
│ ├── categories.ts # built-in category seeding
│ ├── transactions.ts # insert/list/get/delete, dedupe, reports queries
│ └── merchantRules.ts # learned rule storage
├── sms/
│ ├── senders.ts # sender → bank mapping
│ ├── parse.ts # parser registry
│ ├── parsers/ # one file per bank-stream
│ ├── resolveAccount.ts # parsed account-hint → accounts table row
│ ├── backfill.ts # SMS rows → transactions, with dedupe + categorize
│ └── inbox.ts # SMS reads via react-native-get-sms-android
├── categorize/
│ ├── normalize.ts # merchant normalization (lower, strip noise)
│ ├── seedRules.ts # built-in merchant → category map
│ ├── apply.ts # learned rule → seed rule → null
│ └── learn.ts # write learned rule, optional bulk-recategorize
└── utils/
├── date.ts # IST helpers, nowIso, istCalendarDay
└── money.ts # paise ↔ display formatting
SQLite tables (see src/db/schema.ts):
accounts— Cash + one row per detected(bank, kind, last4).categories— built-in + user-defined; unique by name.transactions— one row per parsed SMS or manual entry.amount_paiseis an integer;occurred_atis ISO-8601 UTC;dedupe_keyis the sha256 ofaccountId|IST-day|amountPaise|direction.merchant_rules— learnedmerchant_norm → category_idmappings.unparsed_sms— SMS from supported senders we couldn't parse. Useful for triaging new message formats.sms_inbox_queue— reserved for a future background-receiver flow.settings— key/value store.
resolveCategory(merchantNorm) (see src/categorize/apply.ts) looks up, in
order:
- A learned rule for
merchantNorminmerchant_rules. - A built-in seed rule from
seedRules.ts. null(transaction lands in "Misc").
When you change a transaction's category from the detail screen, the change is recorded as a learned rule, so future transactions from the same merchant are auto-categorized.
- Node.js 20+
- An Android phone with USB debugging enabled (the app is Android-only — see
app.json). - For local Android builds: Android SDK + JDK 17. For cloud builds you only need an Expo / EAS account.
npm installnpm test # one-shot
npm run test:watch # watch mode
npm run typecheck # tsc --noEmitTests use the better-sqlite3 driver (src/db/driver.node.ts) so they run in
plain Node — no emulator required. Parsers, dedupe, categorization, IST date
math, and migrations all have unit tests.
See docs/build-and-install.md for the full
walkthrough. Short version:
# Cloud (no Android SDK needed)
npx eas-cli login
npx eas-cli build --platform android --profile preview
# download the .apk from the link in the output
# Local (requires Android SDK + JDK 17)
npm run prebuild
npm run build:apkThen on the phone:
adb install path/to/expense-tracker.apk
# update an existing install without wiping data:
adb install -r path/to/expense-tracker.apk- App opens to the Onboarding screen.
- Tap Grant access and import — Android prompts for SMS permission.
- Backfill reads up to 180 days of SMS, parses what it can, and stores the
rest in
unparsed_smsfor later inspection. - The Transactions screen opens. Pull to refresh to run a 7-day rescan.
- Tap a transaction → Change category to teach a merchant rule. Past and future transactions from that merchant pick up the new category.
- Tap the + floating action button to add a manual (Cash) transaction.
- All data stays on-device in a SQLite database managed by
expo-sqlite. - The app requests
READ_SMSandRECEIVE_SMSonly. INTERNETis intentionally absent fromapp.json. Auditandroid/app/src/main/AndroidManifest.xmlafterprebuild; if any plugin injectsINTERNET, remove it before shipping. The build-and-install doc flags this explicitly.
| Script | What it does |
|---|---|
npm start |
Start the Expo dev server |
npm run android |
Build + run on a connected device/emulator |
npm run prebuild |
Regenerate android/ (run after dependency changes) |
npm run build:apk |
Local EAS build → APK in build/ |
npm test |
Jest suite |
npm run test:watch |
Jest in watch mode |
npm run typecheck |
tsc --noEmit |
- Add a parser at
src/sms/parsers/<bank><stream>.tsthat takes(body: string, receivedAt: string)and returns aParsedTxn | null. - Add fixtures under
src/sms/parsers/__fixtures__/and a matching<parser>.test.ts. - Register the parser in
src/sms/registerParsers.ts. - Extend
matchSenderinsrc/sms/senders.tsif the sender ID needs a new bank tag. - Run
npm test.
Use unparsed_sms as a source of real-world fixtures: anything the app sees
but can't parse lands there with the original sender, body, and timestamp.
Private / unpublished. No license granted.