DEV Community

Altaaf Hamod | MrVampCruz
Altaaf Hamod | MrVampCruz

Posted on

I shipped a 100% offline personal finance app as a solo dev — here's the full stack

After months of building in evenings and weekends, I shipped Budgetify to the Google Play Store last week. It's a personal finance tracker built around one hard constraint: no servers, no accounts, no data ever leaving your device (unless you explicitly back it up).

This post is about the technical decisions that made that possible — and the tricky parts I didn't expect.


Why build another finance app?

Every finance app I tried either required an account, synced data to their servers by default, or locked basic features (like recurring transactions) behind $10+/month subscriptions.

I wanted something that worked like a proper offline-first tool — fast, private, yours. So I built it.


Stack

  • React Native + Expo (managed workflow)
  • Expo Router (file-based routing — app/(tabs)/ and app/(screen)/)
  • SQLite — all data, all local, expo-sqlite
  • RevenueCat — subscription & IAP management
  • Google AdMob — free tier monetization
  • EAS (Expo Application Services) — builds & Play Store deployment
  • GitHub Actions + EAS Workflows — CI/CD pipeline
  • expo-updates — OTA updates for JS-layer changes
  • expo-in-app-updates — Play Core in-app update prompts
  • react-native-google-signin — Google auth, proxied behind a single module
  • react-native-gifted-charts — spending bar charts
  • dayjs — all date handling (no new Date() anywhere)
  • react-i18next — i18n (en + fr at launch, more locales planned)

The offline-first architecture

All app data lives in a local SQLite database. The schema includes wallets, transactions (credit/debit/transfer), recurring transactions with occurrence tracking, category budgets, and app metadata.

A few patterns that made this work cleanly:

Sequential DB queries, always. SQLite on React Native doesn't handle concurrent writes well. Every query is await-ed sequentially — Promise.all on writes causes crashes. Learned this the hard way.

Transfer transactions use a transfer_group_id. Each transfer creates two rows — one out, one in — linked by a shared UUID. This keeps queries simple while making transfers fully reversible and trackable.

Multi-currency amounts are resolved with a COALESCE pattern:

COALESCE(amount_in_wallet_currency, amount * exchange_rate, amount)
Enter fullscreen mode Exit fullscreen mode

This handles the case where exchange rates were configured at transaction time vs. not.


AES-256 encrypted backups

Backup files are fully AES-256 encrypted before writing to disk or uploading to Google Drive. The encryption key is derived from a secret baked into the app at build time via APP_SECRET in .env.

One critical rule I set early: APP_SECRET must never change after the first production release. If it changes, every user's existing backup becomes unrestorable. This is the kind of constraint that's easy to forget months in when you're rotating keys.

The Google Drive backup flow was the trickiest part — @react-native-google-signin is powerful but has some sharp edges. I ended up isolating everything behind a single proxy module (lib/googleDriveBackup.ts) and enforcing a rule: that package is never imported anywhere else in the codebase. All Google auth flows go through that one file.


CI/CD with EAS Workflows

Two GitHub Actions workflows handle deployment:

  • Push a tag matching preview-* → EAS builds an APK for internal testing
  • Push a tag matching v* → EAS builds an AAB and submits to Play Store (alpha or production track)

OTA updates (JS-layer only) are triggered by including [OTA] in the commit message — no rebuild needed for most changes.

One thing worth knowing: environment variable changes always require a full EAS rebuild. OTA can't patch .env values since they're baked at build time. Same goes for app icons — they're native assets.


Subscription model

Four tiers via RevenueCat:

Tier Price
Free (with ads)
Ad-Free $0.99 one-time
Pro Monthly $1.99/mo (7-day trial)
Pro Yearly $21.99/yr (14-day trial)
Lifetime Pro $89.99 one-time

The free tier is ad-supported via AdMob. Premium users see zero ads. I wrote a BannerAdView component that auto-hides itself for any paid tier — screens don't need to know about subscription state directly.

Feature gating uses a can("feature") hook from useSubscription(), and paywalled UI uses a BlurredUpgradeOverlay component. Keeps the logic out of the screens themselves.


Localization

The Play Store listing launched in 6 locales: en-US, fr-FR, ar, hi-IN, pt-BR, es-419.

One thing that caught me: Play Store descriptions are plain text only — no Markdown, no bold, no dashes for bullets (use instead). And the 4,000-character limit counts CRLF line endings, not just LF. I now verify character counts programmatically before updating listings.


What's next

  • Fingerprint / PIN lock
  • Savings goals
  • Wallet budgeting
  • Home screen widget
  • iOS (eventually)
  • Financial Health Score

Links

Happy to go deeper on any part of this — the SQLite patterns, RevenueCat integration, EAS workflows, or the encryption approach. Ask away.

— MrVampCruz


A few notes:

  • The "learned this the hard way" moments (SQLite concurrency, APP_SECRET) read really well on Dev.to — devs appreciate honesty over polish
  • The SQL snippet and the table give it a technical texture that fits the platform well
  • Tag with showdev — that tag gets strong traction for indie launch posts on Dev.to

Top comments (0)