Inspiration

Every expense tracker I'd used wanted an account, or my transaction history on someone else's server before it would tell me anything about my own money. Fenn started from the opposite premise: the fastest way to capture a spend — typing, saying it out loud, or photographing the receipt — shouldn't require handing the data to anyone. If a phone can now run a real language model on-device, there's no reason a receipt has to leave it.

What it does

Fenn is a private money tracker for iPhone. You add a record by typing it, speaking it, or photographing a receipt; Apple Intelligence reads the capture on-device and returns a structured draft — amount, category, method — which you review before it's kept (each field marked certain or best-guess, nothing saves unread). A home screen shows today, a dashboard shows the shape of the month, and budget and renewal alerts are computed and scheduled on the phone, because a server that could tell you when you'd overspent would have to be told what you spend. There are no accounts, no sign-in, no password — the whole thing runs against an encrypted on-device database, and ships in full English and Arabic, right-to-left included.

How I built it

Expo SDK 57 (managed workflow) + expo-router, strict TypeScript, with the codebase organized into enforced layers (routes → features → services → shared) so dependency direction is an ESLint rule, not a convention. The ledger — the money records themselves — is one module every screen reads and none writes to directly.

Data sits in a SQLCipher-encrypted SQLite database, keyed by 256 bits held in the iOS keychain. Receipt/voice/text capture runs through Apple's Foundation Models framework using @Generable structs, so the model is constrained to the app's own schema — it physically cannot return a category or field the ledger doesn't have. A cloud-parse lane (Supabase edge function) exists for devices without Apple Intelligence, gated by a server-enforced quota against an anonymous identity created lazily on first use — never a signed-in account.

Monetization runs entirely through RevenueCat: a one-time purchase unlocks the on-device model, a subscription lifts the cloud-parse allowance, and the paywall itself is RevenueCat-owned rather than hand-rolled, so pricing and copy can change without an app update. Feature flags and experiments (PostHog) resolve synchronously from a local cache, so nothing in the UI ever waits on a network call — a flag delivery outage just ships yesterday's app.

Every non-obvious decision — why Expo, why SQLCipher over an unencrypted DB, why the paywall belongs to RevenueCat, why there's no server that ever learns what you spend — is written down as an ADR as I made it, 18 of them by launch.

Challenges I faced

Getting the on-device model to actually fit. The original plan downloaded a 2.5GB GGUF model to the phone. Measured on a real device: 0.21 tokens/sec with mmap, and it wouldn't load at all without it. That killed the whole "bring your own model" design a week before I'd planned to ship it, and Apple Intelligence — once I could target iOS 26 — replaced it outright: no download, no storage question, no progress bar.

Guided generation is sensitive to field order, not just wording. With Apple's structured generation, properties are filled in declaration order and each one conditions on the ones before it. Moving one field in the schema took its accuracy from 68% to 18% with the exact same prompt — nothing about the wording changed, only where the field sat in the struct. That turned prompt work into something I had to measure on-device every time, never reason about from an editor.

RTL isn't a mirror flip. Arabic broke things a naive RTL implementation doesn't: iOS text inputs snapping left on focus regardless of layout direction, a date picker whose height silently depends on width breakpoints a headless test can't see. Each needed an explicit fix rather than trusting the OS to handle it.

Encryption changes what "restore" means. SQLCipher meant deciding, up front, what happens when a phone's encryption key doesn't come back after a restore — and writing the one code path allowed to throw the local database away, with the old file kept aside rather than deleted outright.

What I learned

That "no account" is a harder constraint to build against than it sounds — every feature (backup, quotas, entitlements) has to work off an anonymous identity instead of a user row, and that discipline caught real design mistakes early rather than late. And that language-model accuracy on a real device is not something you can reason your way to from a prompt in an editor — every claim in this project came from re-running the same 20 seed receipts and reading the score, not from intuition about what a better prompt should do.

Built With

Share this project:

Updates

Submission history