I stopped typing my expenses
September 1, 2026
15 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.
# Act 1 — Why automate at all
## 1. 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.
## 2. 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.
1. A push notification
2. 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 |
| ----------------------------------------- | ------------------------------------------------------------- |
| `[email protected]` | Transactions made in the banking app. Could be either account |
| `[email protected]` | Savings account |
| `[email protected]` | Money arriving from another bank |
| `[email protected]` | 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.
## 3. 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. Only one sender is ambiguous, because that channel can
charge either account, and for that one the app matches the last four digits printed in
the email against my accounts instead.
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.
---
# Act 2 — Why I stopped trusting it
## 4. 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.
## 5. 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.
`[kalau mau: satu kalimat kenapa ini terasa berbeda dari bagian 3]`
Reconciliation is the part that actually matters. It goes like this.
1. Parse every line out of the statement.
2. Check the arithmetic: `LAST BALANCE + every line = ENDING BALANCE`. If it does not come
out, the import fails and nothing is written.
3. For each statement line, look for a transaction I already have on the same account, the
same date and the same amount.
4. 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.
5. 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.
## 6. 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.
The import also found a transaction sitting in the database twice. SPBU Pertamina,
Rp 320.910.
The cause is that the two sources date the same purchase differently. Gmail tells me the
day I paid. The statement records the day the bank posted it, which is when the merchant
actually settled the charge, and that is usually a day later. For a merchant billed from
abroad I have seen it take three days or more. Same purchase, two dates, and my matching
works on the date, so my code had no way to tell they were the same thing.
I thought about matching within a day or two either side. I decided not to. I buy the same
things repeatedly, and two identical amounts on consecutive days at the same convenience
store is a completely normal week for me. A visible duplicate is something I can see and
delete. A real transaction silently eaten by a fuzzy match is something I would never find.
---
# Act 3 — What I accepted
## 7. 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 above
```
Two 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
One case fits neither list. A card saved on a website does send an email, but the app has
no concept of currency, so a charge billed in another currency is stored as if the number
were rupiah. Ten dollars becomes ten rupiah.
And cash in hand is on no list at all, because nothing anywhere records it.
## 8. 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 given an email from the one sender that is ambiguous, where 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.
## 9. Why the code is not public
Someone always asks, so: the repository is private, and it is staying that way. Not because
of the code, though I am not proud of every line of it. The repository is a map of one
person's bank notifications. Which sender addresses my app trusts, what the parser will
accept, and exactly where it is blind. That is useful to me, useless to anyone else, and
mildly useful to somebody who wanted to send me a convincing fake.
It also only works for one bank, so anyone forking it would be rewriting the parts that
took the longest. And what is worth sharing is not the code anyway. It is the coverage
model, the arithmetic check, and the decision to flag uncertainty instead of hiding it, and
all three are in this post.
## 10. 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