I stopped typing my expenses
September 1, 2026
12 min read
I built an expense tracker that reads my bank's transaction emails so I never excerpt have to type one in. Then I reconciled a month against the bank's own statement and found that 24 of 35 card transactions had never been emailed at all.
The tracker that died
There are a lot of expense tracker apps. I tried making my own sheet. I tried a Google Sheets template. I tried the app with the minimalistic interface, and even the one with the cute interface. Every one of them lasted a few weeks. A month, if it was lucky.
I do not need budgeting, spending goals, charts, or the other features an expense tracker usually has. I only want to know the number I end up with at the end of the month. Did I save some, or did I lose some.
The problem is that the data had to come from me. Every transaction was a form to fill in. Miss three days and I am behind. Miss a week and I cannot remember a thing any more. Miss a month and the app is already abandoned.
What a bank actually sends you
So I did some observation. Banks here do not have an API for a personal account. They have one for merchants and businesses, not for a person who wants to read their own transactions.
Then I noticed that every time I pay with my phone, I actually receive the same event twice.
- A push notification
- An email
At first I thought about reading the notifications. They arrive instantly and they cover everything. But an app that reads other apps' notifications needs a native Android module, which means leaving the managed Expo workflow, and I was completely new to React Native and mobile development. That was a lot of unfamiliar ground for the first thing I ever built.
So I went with email, for two reasons that still hold. An email is still there tomorrow, so I can always go back and check what really happened. A notification is gone the moment you swipe it. And Gmail has an API built for reading mail, which is exactly what I needed.
For my bank the emails come from four different senders:
Sender | What it covers |
|---|---|
Transactions made in the banking app. Could be either account | |
Savings account | |
Money arriving from another bank | |
Credit card |
Those four do not agree with each other. Not on dates: one writes 13 Jun 2026, another writes 23Jun26, another writes 18/06/2026, and one writes 2026-06-28/04:22:37 with the time glued to the end.
Money is worse. Some emails say Rp. 2.250.000,00 and some say IDR 25,169.00. In the first, the dots are thousands and the comma is the decimal. In the second, the comma is thousands and the dot is the decimal. Same bank, same month, and the same character means the opposite thing.
This is the part where I used an LLM, and not because I wanted to use one. Four senders, four date formats, two money formats. I did not want to write a regex for every combination and then maintain it forever.
Letting an LLM read a bank email
The flow is simple. The app fetches email from Gmail one month at a time, filtered to those four senders. Gmail can hand you plain text or HTML depending on the message, so the app asks for plain text first and falls back to HTML. My bank always sends HTML, so it gets stripped down to plain text. A 3,000 character email becomes about 300, and the LLM receives something compact to read.
It returns JSON with a date, an amount, a merchant, a category and a type. Zod validates it. The app writes the row into SQLite on the phone.

There is one question I never let the model answer: which of my accounts a transaction belongs to. The sender address already answers it. CREDITCARD.NOTIFICATION@ is always the card, BIFAST.NOREPLY@ is always the savings account, so the app reads the sender and picks the account in plain code.
I did it this way because a model can be wrong about something like this and still sound completely sure. A sender address cannot.
I should be honest about the privacy trade. The model only receives stripped plain text, never the raw email, and the account has zero data retention switched on, so the requests are not supposed to be stored or used for training. I have no way to verify that from the outside. And in the end it is still a third party reading my bank mail. This is the trade that I chose, and not a problem I solved.
Then there is the notification. I wanted a transaction to show up on my phone within seconds of paying, without opening the app. A sleeping phone cannot do that work for itself, so a small Cloud Run service does it instead. It wakes up when Gmail says there is a new message, parses it the same way, and sends the push. Everything else in the app is offline and stored nowhere but the phone. The relay is the one exception, and it exists for exactly one reason.
Where the LLM broke
Everybody knows LLMs hallucinate. What surprised me was how boring the failures were. It read the two money formats from earlier the wrong way round. It stamped a whole backfilled month with the wrong year, because it used the year my phone was in rather than the year I was importing. And money I sent came back marked as money received.
That last one was my own fault. I had stored the type as debit or credit, because that is what banks say, and credit means two opposite things at once here. A credit on your account is money coming in. A credit card purchase is money going out. The emails contain that word, so I was handing the model a token pointing in both directions and then being surprised when it picked the wrong one. I renamed the two values to expense and income, and a whole class of errors disappeared.
The rest I did not fix with better prompts. Two patterns are decided in plain code before the model's answer is accepted: a top up is always an expense, and an outbound transfer, which I can recognise because the email carries Transfer Type: and Beneficiary Name/Bank, is always an expense. The model can still label them, and the code overrules it. One of the bugs was not the model's at all, by the way. I was splitting the date on a slash, which turned 18/06/2026 into 18.
The honest summary is this. The model is good at reading a messy sentence and telling me what it says. It is not something I want deciding what is true. Everything that can be settled without judgement got moved out of it.
One source is not enough
Even with all of that, I still had no way to know whether a month was complete. Every check I had was the app checking itself, which proves nothing.
I needed a second source the app cannot influence, and I already had one. The bank sends a statement every month.
So I built a statement importer, and the decision I am most sure about is that it does not use an LLM at all. It is regex. One bank, one rigid template, one shape per line. The statement never leaves my phone, and the result is reproducible instead of probabilistic.
Reconciliation is the part that actually matters. It goes like this.
- Parse every line out of the statement.
- Check the arithmetic:
LAST BALANCE + every line = ENDING BALANCE. If it does not come out, the import fails and nothing is written. - For each statement line, look for a transaction I already have on the same account, the same date and the same amount.
- If there is a match, that transaction is confirmed. It gets a small mark in the list so I can see the bank corroborated it.
- If there is no match, the line is inserted as a new transaction. That is the spending the email never told me about.
Step 2 is what makes step 5 safe. If a single line is dropped, misread or counted twice, the sum does not come out. There is no warning and no "import anyway" button, because a check you can click past is not a check. I do not have to trust my own parser. I have to trust addition.
And the measure of a complete month is not the total amount. It is that every line on the statement is now in the app, either matched to something I already had or newly imported.
What the statement found
24 of the 35 credit card transactions that month had never been emailed to me at all.
That is about a third of the money that moved on that card, and the app I built to track my spending had no idea any of it existed.
My bank only sends a credit card email from Rp 100.000 up. Everything below that produces nothing, in any medium. No email, no push, no message. Small things are most of what I buy: coffee, a ride, a convenience store, a subscription. So the app was not slightly incomplete. It was systematically blind to the exact spending I most needed to see.
Account, channel, medium
For a long time my bank's behaviour looked random to me. Some transactions email me, some do not, and the pattern made no sense. It made sense once I stopped treating it as one thing and started separating three.
An account is where money actually lives. A savings account, a credit card. It has a balance.
A channel is the route you take to reach that account. The banking app, the physical card at a shop, an ATM, a card number saved on a website. A channel has no balance of its own.
A medium is how you get told something happened. Email, push, a message.
Savings ─┬─ banking app -> email, any amount
├─ physical card -> nothing
└─ saved card online -> email
Credit card ─┬─ banking app -> email, any amount
├─ physical card -> email, Rp 100.000 and above
└─ saved card online -> email, Rp 100.000 and aboveTwo accounts, not three. My debit card has a different number from my account, which made me think of it as its own thing for a while. It is not. A card is an instrument attached to an account, and one account can have several. They all share one balance.
Once it is drawn out like that, the app stops looking unreliable and turns into two lists.
Arrives by email, so it records itself:
- QRIS paid from the savings account
- QRIS paid from the credit card
- A transfer I send from the banking app
- A transfer arriving by BI-FAST
- Credit card at a shop, Rp 100.000 and above
No email, so it waits for the statement:
- Credit card under Rp 100.000
- Debit card tapped at a shop terminal
- An incoming transfer sent on any rail other than BI-FAST. An online transfer produces no email at all, and I have never tested the slower rails because I do not use them
- Cash taken out of an ATM
And cash in hand is on no list at all, because nothing anywhere records it.
What I chose to accept
The statement import is what covers that second list, and it is the reason I almost never use manual entry. Manual entry is still in the app, and it is still the only thing that can record cash, but in three months I have barely touched it. I wait for the statement instead.
That is the trade I settled on. Four gaps, and I know exactly what they are and when they close, which is a much better position than not knowing.
The other thing I changed is what the app does when it is unsure, and most of that uncertainty comes from the model. It can hand back a date that falls outside the month I am syncing. It can be handed an email from a sender that could charge either account, so the account cannot be resolved with confidence. In both cases the app still writes the row, and puts a small badge on it in the list asking me to check that one thing.
It does not skip the row and it does not pick quietly. A wrong date or a wrong account is something I can see and fix in five seconds. A row that was never written is something I will never know to look for.

The same reasoning killed a feature I wanted. I wanted spending charts broken down by category, and category is the one field the model has nothing solid to work from. It is not printed anywhere in the email. The model has to guess what kind of thing I bought from a merchant string like 9441 INDOMARET OM On Us QRD 552560. I read through a few months of those guesses and did not believe them. So there are no charts. Analysis built on data you do not trust does not give you insight, it gives you confident nonsense.
Three months later
It has been three months since I started using the app, and I have not abandoned it like the others.
Do I open it every day? Of course not, I am still too lazy for that. Do I still know when a transaction happens? Yes, through the real time notification.

I open the app maybe a couple of days a month, and mostly at the end of it, when the statement arrives from my bank. When the import result makes sense, I am satisfied. I close the app, and I do the same thing next month.
I stopped expecting the numbers to match my bank's own app. That app sees every transaction the moment it happens, on every channel, because it is the bank. I only get the part they choose to email me, and then the rest once a month when the statement arrives. So my app is never complete today. It becomes complete about a month late.
Would I build it again, knowing all of this? Yes. I do not type anything any more, and that was the whole point.
Part of Boncos