Rekhon
Mobile · AI · Case studyOpen source · dual-licensed
Android personal-finance app that shows its working — every figure cites the rule and version that produced it. Fully offline and encrypted on the device, with no account and no server. Rupees are integer paise end to end, and a custom lint rule fails the build if a floating-point type ever holds an amount.
- Role
- Sole author
- Team
- Solo project
- Timeline
- July 2026 to present
- Commits
- 349 of 350
- Stack
- Kotlin · Jetpack Compose · Room / SQLCipher · Hilt · ML Kit · Coroutines / Flow · Android Keystore · Gradle
Responsibilities
- Module graph: 60 Gradle modules on a one-way feature → domain → data/core dependency rule
- Deterministic wealth engines: amortisation, XIRR, allocation and concentration
- On-device encryption: SQLCipher keyed from the Android Keystore
- Capture pipeline: ML Kit receipt OCR and bank-SMS parsing behind revocable consent
- 1
Photo
feature/transactions
- 2
On-device OCR
ml/ocr (ML Kit)
- 3
Classification
domain/engines/classification
- 4
Exact paise
core/model/Money.kt
- 5
Encrypted write
core/database (Room + SQLCipher)
- 6
Rule evaluation
ai/rules + domain/engines/insight
- 7
Dashboard + widget
feature/dashboard, widget
DataSQLCipher (Room)Proto DataStoreAndroid Keystore
Situation
Why a personal finance app should show its working
Most finance apps hand you a number with no way to check it: a health score, a projection, a “you can afford this” — with no statement of what was measured or what it was measured against. A figure you cannot check is a verdict. The apps that do explain themselves generally want your bank credentials and a server round trip to do it, which is a steep price for arithmetic that a phone can do on its own.
Task
Building an offline finance app whose figures can be checked
Build a finance app where every figure is checkable and nothing about the money leaves the device. That makes two things non-negotiable rather than nice to have: the numbers must be exact, and the rule behind each one must be addressable — a specific id at a specific version — so an answer stays reproducible months later.
Action
Exact paise, on-device encryption and 60 Kotlin modules
The app is 60 Gradle modules on a one-way dependency rule (feature → domain → data/core), with the engines as pure Kotlin that cannot import Android — so the money maths is unit-testable without a device, and 355 test files holding 3,822 tests run on the JVM. Money is integer paise end to end: a custom Android Lint module flags any floating-point declaration with a monetary name and fails the build, because binary floating point cannot represent 0.10 and the error is too small to notice until a reconciliation disagrees. Room sits on SQLCipher under a key held in the Android Keystore. ML Kit reads receipts and, with explicit consent that is off by default and revocable, bank SMS is parsed into proposed entries — neither leaves the phone. Financial thresholds are versioned JSON rows in ai/rules rather than constants in code, and every insight cites the rule id and version that produced it. 77 ADRs record why each of these is the way it is. The wealth maths is where the determinism requirement bites hardest, and XIRR is the clearest case.
// XIRR is normally NPV(r) = Σ cf · (1+r)^(−d/365), solved by Newton-Raphson from a guess.
// Both halves are hostile to exactness: the fractional exponent has no exact decimal form,
// so an implementation reaches for Double; and Newton's answer depends on the starting guess.
//
// Substituting x = (1+r)^(1/365) and multiplying through by x^dMax clears every division:
// F(x) = Σ cf_i · x^(dMax − d_i)
// — a polynomial with whole-number exponents, so BigDecimal.pow is all it needs.
private fun bisect(
signAtLow: Int,
values: List<Long>,
exponents: List<Int>,
): BigDecimal {
var low = X_LOW
var high = X_HIGH
// Fixed iteration count over a literal bracket, with no early exit: no guess to depend
// on and no tolerance branch to drift, so the same flows give the same basis points on
// every machine and every build. Slower than Newton, and it does not care.
repeat(ITERATIONS) {
val mid = low.add(high).divide(TWO, CONTEXT)
if (polynomialAt(mid, values, exponents).signum() == signAtLow) {
low = mid
} else {
high = mid
}
}
return low.add(high).divide(TWO, CONTEXT)
}Result
Fourteen epics, 3,822 tests and a public repository
Fourteen epics are built and verified — foundation, onboarding and security, capture, categorisation and budgets, dashboard and export, wealth, goals, backup, the AI core engines, the advisor and chat, privacy hardening, release quality and expansion — at v0.13.9, with the most recent committed work dated 10 October 2026. Eleven account types roll up into a daily net-worth snapshot; credit cards carry their real billing cycle and utilisation; loans amortise to the rupee; holdings carry dated lots and a money-weighted return. The source is public on purpose — the central claim is that nothing about your money leaves your device, and that is not something a store listing can prove.
349
of 350 commits authored
3,822
tests across 355 files
60 modules
one-way dependency graph
Where it stands
Roadmap
- Version
- 0.13.9
- As of
Shipped and verified14
Epic 0 — Foundations and the AI blueprint
v0.1.0
Epic 1 — Foundation and core platform
issues 1.1–1.10
Epic 2 — Onboarding, security and accounts
v0.2.0
Epic 3 — Transactions and capture
v0.3.0
Epic 4 — Categorisation and budgets
v0.4.0
Epic 5 — Dashboard, export and widget
v0.5.0
Epic 6 — Wealth: loans and investments
v0.6.0
Epic 7 — Goals and emergency fund
v0.7.0
Epic 8 — Backup and restore
v0.8.0
Epic 9 — AI core engines
v0.9.0
Epic 10 — Advisor and chat
v0.10.0
Epic 11 — Privacy and security hardening
issues 11.1–11.7
Epic 12 — Quality, testing and release
v0.12.0
Epic 13 — Expansion
v0.13.0
Most recent committed work3
`import_batches` exists, and the wipe is checked rather than trusted
Closed the import-batch finding ADR-0074 had left open, and put a coverage check behind the wipe instead of taking it on trust.
ADR-0078
The GST figure is stored, and `null` is not zero
An absent tax figure and a zero tax figure are different claims, so they stopped sharing a representation.
ADR-0077
The engine registry is reconciled, and now checked
The registry had drifted from the engines it lists; the reconciliation is now enforced rather than periodic.
ADR-0076
Open findings1
Opened by ADR-0078 while closing the import-batch finding from ADR-0074.
ADR-0078
Designed, not built1
Account Aggregator integration
The design landed and one reserved source value shipped with it, because the enum had to be right before data existed. The integration itself is not built.
ADR-0074 · v0.13.5
Blocked1
iOS through Kotlin Multiplatform
“Android-free” turned out not to mean “KMP-portable”. Money is the blocker: the exact-paise type is the part that does not port, which makes this a rewrite of the one thing that must not change.
ADR-0075 · v0.13.6
Read from Rekhon CHANGELOG.md and docs/adr/ on the public dev branch. Items that are designed, open or blocked are labelled as such and are not claimed as shipped.
Build journal
How the thinking moved
Dated entries on the decisions behind this project, each linking to the record it came from.
- XIRR without floating pointA SIP’s cost-to-value ratio understates its return by roughly half, so the app needed a money-weighted return. The textbook way to compute one reaches for Double and a starting guess — both unusable here.
- A lint rule instead of a reviewerRupees are integer paise in Rekhon, and the rule is enforced by a custom Android Lint module that fails the build. The interesting part is why a reviewer was not enough.
- Money is a Long, and Double fails my build0.1 + 0.2 != 0.3. In a finance app that is not a curiosity, it is a bug you ship and find months later.
- The seven rules I cannot breakSeven rules written down before the first line of application code, under a heading saying they override feature requests. Six of them later became detectors that fail the build.
- Numbers from math, words from AIA language model in a finance app will eventually state a confident, wrong rupee figure. The architecture answers by never handing it one to state, and checking the sentence afterwards anyway.
Looking back
What I would do differently
- Account Aggregator is designed and one reserved value has shipped, but the integration itself is not built (ADR-0074).
- iOS through Kotlin Multiplatform is blocked rather than scheduled, and the blocker is Money: the exact-paise type is the part that does not port (ADR-0075).
- ADR-0078 closed the import-batch finding and opened one of its own, on orphaned receipt blobs.
- Publish the golden-file vectors for the wealth engines, so a reader can re-run the money maths without building the app.