# Log an expense in the time it takes to say it.

Mooney is an Android expense tracker, live on Google Play, built and run by OCTYN. You say one sentence and a categorised transaction is saved, end to end in under five seconds on a live network. Speech and intent run on outside services, and every call to them goes through OCTYN’s server, never straight from the phone.

Domain: Our own · Mooney
Label: Running (our own)
For your product team. Inside: speech recognition · transaction parsing · fallback chain
Link: usemooney.app (https://usemooney.app)

## Dated figures

- ~50 installs (recorded 2026-07-06)

Mooney is OCTYN's own product, built, shipped to Google Play production, and operated by OCTYN.

## The situation

First commit 2026-04-10. Logging an expense by hand costs more than the expense is worth, so people stop. Say one sentence, get a categorised transaction, and the habit survives. The constraint was the phone: Android, where the microphone has to work from a home screen widget without opening the app, and every outside service sits on a free tier with a hard ceiling.

## Why it was hard

Android will not let one app run its main process and a widget's process as a single unit, so the widget microphone runs in a separate process of its own. It cannot open the local database, because the main app holds the lock, and it has no signed-in session. A capture from the widget has nowhere to write and nobody to write as. Naive versions work in development and fail on a device, because Android clears the cache directory under memory pressure, which is exactly when widgets fire.

## What was built

A local-first app, so every feature works with no network. A spoken transaction records audio, goes to a speech-recognition pass tuned for the local spoken form of numbers and currency words, then to a fast text-parsing pass that reads intent out of the transcript, escalating to a slower, more careful pass only when the fast one comes back unsure.

Every outside call goes through a small server-side function instead of straight from the device. No key ships inside the app, keys rotate without a release, and rate limiting sits server side where it sees every user instead of one phone. Sixteen functions sit on that seam.

## What it does under load

Repo version 1.0.1 build 11 on 2026-06-20, about 87 days from first commit to a week-one reading of roughly 50 installs. Audited against provider ceilings on 2026-04-25: three parsing keys capped at 28 requests a minute each against a ceiling of 30, and two speech-recognition keys carrying roughly 1.4 million voice turns a month, which puts the ceiling at 1,500 to 2,000 daily active users before requests queue.

End of speech to saved row lands under five seconds. That figure comes from screen recordings on a phone over a live network, not from instrumentation, so it is a demonstrated wall-clock time, not a measured percentile, and it is published as what it is. Per-stage timing is not yet recorded in the app, which is the first thing the closing section says should change.

## What happens when it breaks

Every provider has a documented failure path, audited in an internal record rather than assumed.

Key rotation is one shared module: a rolling 60 second window per key, a 60 second cooldown on any rate limit, server error or throw, round-robin to the least loaded healthy key, and a log line per hop naming the key and the reason. Intent parsing degrades through four stages, a fast model to a bigger one to two further backups, so all three keys on the first pass can fail at once and users see nothing wrong. Receipt reading degrades to a text-only pass, then a second provider, then a plain error the client renders as "couldn't parse, tap to retry". Speech has no second provider: if both keys on that leg fail, voice stops. Known, deliberate, written down.

Errors land in five places, split by what each can see: one service for app-side stack traces and release tags, a second for a parallel event showing what the user was doing before the failure, the server side for its own failures, the Play console for native crashes the app itself never sees, and the billing provider for payment failures. A decision table names which surface to open first per symptom, and after every release a ten minute smoke test fires a deliberate crash to confirm it still arrives. Rollback is a staged release, so a bad build halts at a percentage rather than being recalled.

## Who maintains it, and what that costs in attention

One person per incident, in practice.

Steady state is quiet by construction. Local-first means a server-side outage pages nobody, key exhaustion self-heals, provider outages route around themselves, and the rotator writes down what it did, so the question afterwards is answerable without reproducing anything.

What costs attention is Android, not the intelligent part. OEM behaviour changes break the notification listener and the widget host silently. They arrive as a store vitals number, never as an exception, and they need a device on a cable rather than a laptop. That cost is seasonal, tied to OS releases. The other standing commitment is the post-release smoke test, because skipping it is how monitoring dies unnoticed.

## What changes when requirements change in six months

Configuration, no deploy: keys, per-tier daily caps, free and paid limits, which provider is primary. Adding a fourth key or moving one to a paid tier changes no code, because the least-loaded logic routes to the higher ceiling on its own.

A small code change: a new expense field, a new category set, another speech language, a fifth fallback. The server-side functions deploy independently of the app, so a server-side change reaches every phone already running it immediately, without a store review. That is why the seam is where it is.

A larger code change: anything altering what the parsing step returns, because the prompt, the sanitiser and the expense model are coupled, or anything touching the widget process, because two processes is structural.

A rebuild: team accounts, because storage is a local file with no schema and no migrations, right for a single user and wrong for shared data. Also an iOS build, because the widget, listener and overlay code is written for the other operating system entirely.

A new provider is an afternoon. A new field is a day. Shared accounts is a storage rewrite, and that would be said plainly before taking the work.

## What we would do differently

Instrument latency on day one, because the product claim is about speed and nothing measures it. Give speech a second provider, since every other leg has one. And build the on-device path first: the on-device short-circuit arrived after the cloud path existed, and built first it would have shaped the whole receipt flow.

## Related

- [Product systems](https://octyn.co/systems/product)
- [Let customers log their day by speaking.](https://octyn.co/casestudies/voice-logging)
- [The Consult](https://octyn.co/consult)

Canonical: https://octyn.co/casestudies/mooney
