Neraca
Neraca is the expense tracker I built for myself after giving up on the ones I could download. It runs entirely on the phone, with no account and no server, and underneath the ordinary-looking screens it is a real double-entry ledger.
- Expo
- React Native
- TypeScript
- SQLite
- Drizzle ORM
- Zustand
Every Tracker Lies About the Same Three Things
I tried a lot of expense trackers before writing my own, and they all got the same things wrong.
Move money from my bank to my savings account and the app counts it as spending. Pay off a credit card and the app counts it twice, once when I bought the thing and again when I paid the bill. Put money into an investment and the app tells me my month was terrible.
None of that is spending. It is my money, in a different pocket.
The reason they get it wrong is structural. They store a balance column on each account and then patch it on every write, and they store a transaction as a single row with one amount and one sign. Once that is your model, a transfer has nowhere to live, so it gets bolted on as a special case, and every report has to remember to exclude it. Sooner or later one of them forgets, and the balance drifts away from the transactions that are supposed to explain it.
A Ledger, Not a List
So Neraca does not store a balance anywhere. There is no current_balance column on the accounts table, and a test asserts that it never comes back.
Every transaction is a header plus a set of signed postings that must sum to zero. A Rp210.000 lunch is not one row, it is two: money leaving the account, and the same amount arriving at a category. The balance of an account is always the sum of its postings, computed on the spot. It cannot drift, because there is nothing to drift from.
What that buys is that the product rules stop being rules at all and become structure:
- a transfer posts to two accounts and no category, so it is invisible to spending analytics by construction, not because a query remembered to exclude it
- a credit card payment is a transfer between an asset and a liability, so the expense is counted once, when you bought the thing
- funding a savings goal moves money sideways, so it never reads as money gone
- someone paying you back reduces what they owe rather than counting as income, because it was your money all along
That last one is a sentence in the app, not just in the schema. Every one of these was a bug in some other tracker I used, and here they are all the same fix.
Money Is Never a Float
Every amount in the database is an integer in minor units, and the money layer asserts integrality on every single operation. Nothing in the app is ever allowed to hold Rp210.000,00000001.
Splitting a bill is where this gets sharp. Divide Rp360.000 three ways and the naive answer is fine, but divide Rp100.000 three ways and rounding each share independently loses a rupiah. Neraca uses largest-remainder allocation, so the shares always add back up to exactly the total, and the person who absorbs the odd unit is chosen deterministically rather than by accident.
Currency exponent is a first-class field rather than a formatting detail, so the same stored 35000 renders as Rp35.000 or $350.00 depending on the account it sits in.
The lint config carries a rule I am unreasonably fond of: parseFloat is banned outright. In Indonesian locale formatting, "1.234.567" is one and a bit million rupiah, and parseFloat silently returns 1.234. That is a bug that loses you six figures without throwing anything, so the rule points at the project's own parser instead. console.log is banned in the same file, because financial data should never end up in a log.
Three Places to Catch a Broken Number
A ledger is only worth having if it is actually always balanced, so the invariants are enforced at three different depths.
In SQLite, as CHECK constraints, for everything that can be expressed in one row. Amounts cannot be zero. A business date has to match a date-shaped pattern. The month cached on a transaction has to equal the first seven characters of its own date. One constraint makes a half-populated posting impossible: a posting points at an account, or a category, or a contact, and never at two of them.
In the pure builder, for the two rules SQLite cannot see, because they span rows: postings must sum to zero, and category postings must carry the right sign for the kind of transaction.
At runtime, as a scanner the user can run. It walks the whole database looking for transactions that do not balance, orphaned postings, header caches that disagree with their postings, and splits that do not add up to their parent. It is in Settings as Check my data, and on the sample dataset it comes back with "Everything balances."
Writes go through a serialised queue inside a transaction, so a double-tapped save cannot interleave, and editing a transaction deletes and rebuilds its postings rather than patching them in place. Foreign keys are explicitly switched on and then verified at startup, because expo-sqlite quietly leaves them off, which would turn every ON DELETE RESTRICT into a suggestion.
Screenshots
The dashboard, the activity list, analytics, goals, net worth, credit cards, investments, a person's outstanding items, the repayment sheet, the entry modal, the split editor, and the integrity check coming back clean.
What It Actually Does
The ledger is the foundation, but the app is meant to be used every day, so it covers the shape of how I actually spend.
Accounts across cash, bank, e-wallet, savings, investment and credit card, each with its own currency. Shared expenses, where only my own share becomes spending and the rest becomes money owed to me, netted per person so one friend is one relationship rather than a list of receipts. Savings goals with pacing, which tells me what I would have to put aside each month to actually make the date. Credit cards with real statement cycles, including the part where a statement day of the 31st has to clamp in February. Instalments, because buying on cicilan is one purchase and twelve payments, not twelve purchases.
The dashboard tiles are configurable from a catalogue, and the one I use most is safe to spend per day: the balance spread across the days left in the month. It rounds down and counts today as a remaining day, so the divisor can never be zero and the number can never flatter me.
Testing, and a Test Runner That Enforces Architecture
There are 410 test cases, and the way they are split does a second job.
They run as two Jest projects. The logic project covers the calculation layer and runs in plain Node with no Expo preset. That makes it fast, but more usefully it makes it a dependency rule with teeth: if anything in the money, date or ledger layer ever grows an expo-* import, those tests stop resolving. The architecture boundary is enforced by the test runner rather than by a lint plugin everyone learns to ignore.
TZ is pinned to Asia/Jakarta so that month-boundary bugs surface in CI instead of on somebody's phone at 11pm on the 31st. Business dates are plain YYYY-MM-DD strings with an optional separate time, never Date objects, because a timestamp is the wrong type for "which day did I buy this on."
The schema test is the one I would point at first. It runs the real generated migration against real SQLite and then tries to insert rows that should be impossible, proving the CHECK constraints and foreign keys actually bite rather than just existing in a file.
Tech Stack
- Expo SDK 54 + React Native - one codebase, running through Expo Go day to day
- expo-sqlite + Drizzle ORM - the whole database is a file on the phone, with generated migrations
- Zustand - the store holds raw postings and nothing else; every figure on screen is derived by a pure function at render, so there is no cache to go stale
- A hand-written chart on react-native-svg rather than a charting library, so the bars can be tappable and carry proper accessibility labels
Looking Ahead
Neraca is the app I open several times a day, which is the only review of it I really trust.
Plenty of the schema is ahead of the interface. Recurring transactions, reminders and an email import pipeline are all modelled and tested but have no screens yet, and the backup is export-only until I build the restore path. Those are the next stretch.
The part I would not change is the foundation. Starting with a ledger instead of a list of rows felt like overkill for a personal expense tracker for about a week, and has paid for itself every time since.
Want to talk about this work? Email me.