Karan Sharma

Rewriting Gullak: SQLite -> .ledger files (and WhatsApp)

4.0 minutes (1008 words)

So… I rewrote Gullak. Again.

If you missed v1: I built Gullak last year — Go + Vue + SQLite + Telegram — and the whole gimmick was: I can type something like “Coffee 150” and it gets logged. It worked. Saumya used it too. Life was good.

And then slowly… it started annoying me.

TL;DR: Gullak v2 stores transactions in a .ledger file (double-entry), uses WhatsApp (because… India), streams responses to a tiny web UI, and forces every LLM output through a “preview + confirm” workflow. Also yes, there is still SQLite in the stack (lol) — but only for chat history, not for the ledger.

Why I ditched SQLite (for transactions)#

The biggest problem with v1 wasn’t “SQLite bad”. SQLite is great.

The problem was: I ended up treating my expenses like “application data”. Which meant:

What I actually wanted was: a boring append-only journal that will outlive the app.

Enter: Plain Text Accounting and ledger-cli.

Now Gullak writes transactions like this (this is literally what it writes):

2026/02/07 Swiggy
    ; gullak:id a1b2c3d4
    ; gullak:source whatsapp
    ; gullak:user 919876543210
    ; Lunch delivery
    Expenses:Food:Delivery  350.00 INR
    Liabilities:CreditCard:Axis  -350.00 INR

It’s a text file. I can open it in vim. I can git commit it if I want. And every transaction is double-entry, so totals and balances actually mean something.

“LLM-powered” but with seatbelts#

LLMs are great at parsing. They are also great at confidently being wrong.

So in v2, the LLM does not directly “save an expense”. It creates a preview (a pending transaction), and I confirm it.

A very real thing I added: pending transactions are persisted to disk in a .pending.json file so restarts don’t nuke the state. Because of course the one time you’ll need it is when Docker restarts mid-conversation.

When I confirm, Gullak writes to the ledger file and runs ledger-cli validation. If the transaction would make the file invalid (unbalanced postings, etc.), it simply refuses to write. Simple and brutal.

WhatsApp, because that’s where my life is#

Telegram was fine for v1, but it’s not where my life happens.

WhatsApp is where:

So I built a small WhatsApp bridge using Baileys. It’s not a “puppet browser” — it talks the WhatsApp Web protocol over WebSockets.

Architecture is intentionally boring:

Also: I run an allowlist. Only my number (and Saumya’s / our group) can talk to it. I am not trying to build the next “expense bot” SaaS here.

The stuff that makes it feel “fast” (without pretending it’s magic)#

Two tricks that helped a lot:

1) Regex patterns for obvious merchants#

There’s a big list of regex patterns for common Indian merchants (Swiggy, DMart, Netflix, ACT, etc.). So even if the LLM is having a “creative” day, the system has a strong prior.

Example vibe:

2) Payee memory (stored inside the ledger file)#

When I confirm a transaction, Gullak learns a mapping and stores it as a comment in the ledger file:

; gullak:payee_map Swiggy=Expenses:Food:Delivery|Liabilities:CreditCard:Axis

Next time, it can suggest both the expense account and the payment account.

And no, it’s not some fancy embedding model. It’s SequenceMatcher with:

It’s dumb in a good way.

Receipt OCR (aka: forward the photo, don’t type)#

This was missing in v1 and honestly it’s the feature I use the most when we travel.

You can send a photo/PDF of a receipt (WhatsApp or web UI). Gullak runs it through a vision-capable model (configured separately if you want), extracts merchant/date/amount, and creates pending transactions.

Then I just “confirm” or “edit that” like a normal chat.

CSV import (for the monthly ritual)#

If you’re doing PTA seriously, you know the ritual: download statement → import → categorize → fix duplicates → cry.

Gullak has an import_csv tool now:

The monthly ritual becomes reconciliation, not data entry (which is what I wanted all along).

The stack (v2)#

I kept it intentionally small:

Docker compose runs three services: Gullak + Paisa + WhatsApp bridge, sharing a /data volume. Timezone is set to Asia/Kolkata because relative dates like “yesterday” should not become an accounting surprise.

Paisa for reports (because I don’t want to build charts)#

Paisa is fantastic. Gullak is good at capture; Paisa is good at telling me “where is the money going”.

After every confirmed write, Gullak triggers a Paisa sync, so the dashboard stays updated without me doing anything.


Source code: https://github.com/mr-karan/gullak

If you try it and it breaks (it will), feel free to open a GitHub issue or reach out.

Fin!

Tags: #sideproject #ledger #plaintext #ai #python #whatsapp