Documentation
Architecture
How Alkemi works under the hood — for the technically curious.
Stack
Alkemi is an Android-only app built with React Native and Expo. There is no backend server, no cloud database, and no telemetry service.
| Layer | Technology |
|---|---|
| Framework | React Native + Expo (managed workflow, custom dev client) |
| Database | expo-sqlite — on-device SQLite with WAL mode |
| State | Zustand (client state) + TanStack Query (DB cache) |
| Navigation | expo-router (file-based) |
| Encryption | react-native-libsodium (XChaCha20-Poly1305 + Argon2id) |
| Biometrics | expo-local-authentication |
| SMS capture | Android BroadcastReceiver (native module) |
| Notification capture | Android NotificationListenerService |
Data flow
Every transaction follows the same path regardless of how it was captured:
Bank SMS arrives via BroadcastReceiver, or a notification fires from a trusted app via NotificationListenerService.
ParserRulesEngine applies regex patterns to extract amount, merchant, account number, and direction (debit/credit). Learned corrections from parserLearning.ts are applied on top.
ExpenseOrchestrator.addExpense validates the parsed result, runs deduplication (content hash + time window), resolves the account (falling back to the default Cash account), and delegates to the repository layer.
ExpenseRepository inserts the transaction into SQLite (amounts stored as integer paise — rupees × 100). AccountOrchestrator adjusts the account balance.
TanStack Query invalidates the relevant cache keys. The UI re-renders with the new transaction visible in the feed and reflected in budget/analytics.
SMS & notification parsing
The parser (src/services/parser.ts) uses a two-pass approach:
- Sender matching — the SMS sender ID or notification package name is checked against a list of ~30 trusted senders/apps. Unknown senders are ignored.
- Pattern matching — 50+ regex patterns cover Indian bank SMS formats. Patterns extract amount, merchant name, last-4 card digits, and transaction direction.
When the user corrects a parsed transaction (wrong merchant, wrong category), parserLearning.ts records the correction keyed to the sender. On the next matching SMS from that sender, the correction is applied before the generic patterns run.
Deduplication uses a content hash over (amount, direction, account, timestamp-window) to prevent the same transaction appearing twice when both SMS and a notification fire for it.
Storage & schema
All data lives in a single SQLite file (alkemi.db) in the app's private data directory. Key tables:
| Table | Purpose | Money column |
|---|---|---|
transactions | Main ledger | amount INTEGER (paise) |
accounts | Bank accounts, cards, wallets, cash | — |
account_balance_events | Balance anchors from SMS | balance INTEGER (paise) |
goals | Savings goals | target_amount, current_amount (paise) |
debts | Who owes whom | amount INTEGER (paise) |
profiles | Multi-profile support | — |
kv_store | Settings, flags, onboarding state | — |
schema_migrations | Migration version tracking | — |
Amounts are always stored as integer paise (rupees × 100). Conversion happens exclusively at the repository boundary — mapRowToExpense divides by 100 on read, and INSERT/UPDATE multiply by 100 on write. This avoids floating-point rounding errors on aggregation.
The schema evolved through 6 forward-only migrations. PRAGMA foreign_keys = ON is set on every database open. A CHECK constraint on transactions.direction enforces that transfer rows always have both counter_account_id and entry_group_id.
Encryption
The on-device database is not encrypted at rest — it relies on the Android app sandbox for isolation. Field-level encryption was considered and removed; only backups are encrypted.
When you export a backup:
- A random 16-byte salt is generated.
- Your password is run through Argon2id (64 MiB memory,
opslimit=2) to derive a 256-bit key. - The database is encrypted with XChaCha20-Poly1305 (IETF variant) — authenticated encryption that detects tampering.
- The encrypted file (
.alkemiextension) is passed to the native Android share sheet. Alkemi never sees where it goes.
kdf: Argon2id13 · m=65536 · t=2 · p=1
salt: 16 bytes (crypto_random)
library: react-native-libsodium
Restore decrypts the file in memory, verifies the authentication tag, and replaces the local database. A wrong password is detected immediately by the failed AEAD tag verification.
Privacy model
Privacy in Alkemi is architectural, not policy-based:
- No
INTERNETpermission in the Android manifest for financial data access — the app cannot make outbound HTTP calls for your data even if compromised code were injected. - No analytics SDK — Crashlytics, Firebase, Mixpanel, and similar are not present in the dependency tree.
- No telemetry — a remote logger stub exists in the codebase but is never wired to a transport.
- Minimal permissions — only SMS, notification listener, and foreground service. No location, no contacts, no camera.
Invariants
These are correctness rules enforced in code. Breaking one causes a data bug, not just a style issue.
- Amounts are integer paise in the DB. Rupees (float) only in TS/UI. Never aggregate rupee floats — SUM paise, divide at the end.
directionenum isdebit | credit | transfer_out | transfer_in. Transfer rows require bothcounter_account_idandentry_group_id(enforced by DB CHECK).transactions.account_idis NOT NULL. "Unassign" means reassign to the default Cash account — never SET NULL.- Every query is profile-scoped via
ScopedQueryBuilder(profile_id = 'default'). - Credit-card bill payments are stored as
creditwithis_excluded = 1— never as a transfer.