[ CASE STUDY · 03 / 03 ] ——— MÔNEY · V1.2.5 · LIVE
satoshi@case-studies ~ $ cat money.brief

A ledger that never phones homeand still gets smarter every week.

A local-first personal finance app. No bank linking, no scraping, no telemetry. Data sits in AsyncStorage, backups are JSON files the user owns, and the insights engine runs entirely on device.

Local-first architecture Multi-currency Scheduled txs On-device insights RevenueCat + BTC unlock
[01]
AT_A_GLANCE/
0
bytes sent by default
network paths are opt-in — FX rates, RevenueCat, optional lead email
3
opt-in network paths
and nothing else ever leaves the device
90d
schedule sync window
dedup by {scheduledId, occurrenceAt} · capped at 100/run
JSON
own your backups
portable files · defensive import · never destructive
[02]
THE_PROBLEM.MD

Every “privacy” finance app eventually asks for your bank password.

Personal-finance apps either scrape your bank (and sell aggregated data) or give up on automation. The brief was to find a third path: manual entry that doesn’t feel manual, and insights that don’t require a server.

The app had to feel fast on a 3-year-old phone and work in airplane mode — because that’s where most people actually log expenses: on a train, in a shop, with two bars.

A.
Manual entry is the floor
If logging a coffee takes more than four taps, the user abandons the app in week two. Speed is the feature.
B.
No server, but still smart
Users want categories, merchants, and amounts to autocomplete — without a backend to train a model.
C.
Multi-currency without drift
A traveller logs € in Paris and £ in London. Balances must stay honest and FX must not re-write history.
D.
Backups must be boring
Boring = predictable. A JSON file the user can email to themselves is worth more than any encrypted cloud sync they don’t trust.
[03]
APPROACH.MD
// FOUR MOVES

Pick a local-first posture, then refuse to break it.

Local-first is an engineering constraint, not a marketing word. Every feature had to degrade gracefully when offline, every piece of state had to survive a backup round-trip, and every network call had to be explicit enough to list on one hand.

[01]
AsyncStorage is the DB
All state lives on device: accounts, transactions, scheduled templates, rates cache, settings. Zero required network.
[02]
Auto-learn on device
autoCategory / autoAccount / autoAmount / autoTokens rebuild from history on boot — suggestions appear as the user types, no model required.
[03]
Scheduled as first-class
Templates generate txs inside a 90-day window, deduped by {scheduledId, occurrenceAt}, capped at 100/run so a bug can never flood the ledger.
[04]
Backups you can email
JSON files with stable top-level keys. Defensive import: an invalid payload cannot overwrite current state. Ever.
[04]
INSIGHTS_ENGINE/

Trends, pace, and anomalies — computed the minute the app opens.

A pure function takes (accounts, txs, scheduledTxs, rates, settings, now) and returns a fully-shaped insights object: a 12-month trend per category, a 6-month monthly pace, a 3-month anomaly window. No debouncing, no background sync, no spinner — the math runs in single-digit milliseconds on a phone.

Multi-currency is honoured at the transaction timestamp: each tx exchanges through the rates cache as of when it was entered, so historical balances never silently drift.

// DESIGN TENET
An “offline” app that silently needs the network for its best features is not offline. Every insight in Môney must be computable with the phone in airplane mode.
FIG. 03 — TRAILING 12M · ON-DEVICEsatoshi-ltd/money
TREND
+12%
12-mo category drift
PACE
68%
6-mo monthly pace
ANOMALY
▲ food
3-mo window
[05]
SCHEDULE_SYSTEM/
// RECURRENCE

Recurring transactions that can’t duplicate themselves — even across re-installs.

Each scheduled template computes its own occurrences via a pure recurrence module (weekly by weekday, monthly by day-of-month, yearly). At boot, the store syncs the 90-day window, materialising only occurrences that aren’t already stamped with a matching {scheduledId, occurrenceAt} key.

A hard safety cap of 100 auto-created txs per run means that even a pathological state (clock skew, corrupt pattern) can’t pollute the ledger. Notifications follow the same law: 8 per template, 48 total.

runScheduledSync(now) {

  window  = [now - 90d, now]
  for (s of scheduledTxs):
    for (ts of occurrences(s, window)):
      key = ${s.id}:${ts}
      if key in existingMeta: skip
      else:            materialise tx
      if created++ > 100: break // safety cap

  syncScheduledNotifications({
    horizon: 90d,
    perTemplate: 8, total: 48,
    trigger: "day - 1 @ 08:00 local",
  });
}
PERSISTENCE
AsyncStorage
accounts · txs · scheduled · rates · settings
STATE
Context + reducers
small surface, migrate-on-boot, versioned schema
OFFLINE
First-class
every core flow works in airplane mode
FX
Per-timestamp
exchange at tx time, never rewrite history
PREMIUM
RevenueCat
local BTC unlock path preserved on sync
AUTH
PIN lock
optional · amount masking on background
CHARTS
Gifted Charts
trend · pace · category rings · pure RN SVG
BACKUPS
JSON round-trip
defensive import · never overwrite on error
[06]
BRAND/

Warm paper. Quiet numbers. Nothing shouting for your attention.

Most finance apps feel like an accountant’s Excel rendered in neon. Môney takes the opposite bet: warm cream, brown ink, one gold accent — an interface that rewards quick glances and gets out of your way.

Both light and dark themes share the same ink-on-paper logic. Numbers use the primary family, labels use the secondary. No drop shadows on money values. No bright red for expenses — just a slightly warmer brown.

M
Môney
BRAND SPECIMEN
#f3f2ee
paper
#EDE9E4
surface
#473729
ink
#FFBC2d
accent
#8B4513
danger
APRIL · BASE €€ 4,218.60
Rent · scheduled− 950.00
Coffee · Madrid− 2.40
Freelance · Lisbon+ 1,200.00
[07]
OUTCOMES.TXT
  • Shipped and stable
    v1.2.5 live on iOS (15.1+) and Android, running Expo SDK 54 + React 19 with zero-downtime migration path since v1.0.
  • Three network paths, total
    FX rates · RevenueCat · optional lead email. Everything else stays on device — auditable in one grep.
  • Fast on old phones
    Insights engine runs on open; no spinners, no background workers, no cloud function invocations to debug.
  • Migrations without drama
    Schema version + defensive backup import means users can upgrade, downgrade, and restore from JSON without losing a byte.
// WHAT WE LEARNED
“Local-first isn’t a marketing word. It’s a discipline: every feature has to earn the right to make a network call, and most features never will.
— satoshi-ltd engineering notes
← PREV
Clonara
case study · 02 / 03
↺ FIRST
SplitPass
case study · 01 / 03