DEV Community

Rajesh Mudi
Rajesh Mudi

Posted on

I Got Sick of Subscription Budget Apps. So I Built My Own With Telegram, Python, and a $0/Month Stack.

No logins. No monthly fees. No "Premium required to export your own data." Just you, a Telegram message, and a double-click.

Try it now: @PennyTrak_bot


Every January I download a new budgeting app. Every March I forget to open it. By April, I'm back to squinting at my bank statement trying to reverse-engineer where ₹40,000 went.

It wasn't a discipline problem. It was a friction problem. Every app wanted me to log into a dashboard, find the right category, tap through three menus, and somehow remember to do this after every transaction. I don't do that. Nobody does that.

But I do check Telegram constantly. So I asked myself a dumb question: what if logging money felt exactly like texting a friend?

Me: spent 500 on ola
Me: swiggy 420 dinner
Me: got salary 75000
Enter fullscreen mode Exit fullscreen mode

No menu. No category dropdown. Just words. That's the whole idea behind Ek Ek Paisa ka Hisab — a personal finance system that lives in Telegram, persists to Supabase, and surfaces in a static HTML dashboard you open with a double-click.

Here's how it works, and why a few of its design decisions might be worth stealing.


The Architecture in One Sentence

A Python bot parses your natural-language messages, writes them to a Postgres database, then regenerates a self-contained HTML file that opens offline with no server.

That last part is the unusual bit. Most developers would reach for a React dashboard served from a cloud function. I went the other direction: the dashboard is a single .html file with your spending data baked in as a JavaScript variable. Double-click it. Done. No internet, no login, no third party seeing your rent figure.

window.EXPENSE_DATA = [
  {"date":"2026-08-01","category":"food","amount":420,"note":"swiggy dinner","type":"expense"},
  ...
];
Enter fullscreen mode Exit fullscreen mode

The file regenerates after every Telegram message. The dashboard reads it via a <script src="data.js"> tag — not fetch(), which would require a server for the file:// protocol. It's an old trick, but it works perfectly and the whole thing loads in a blink with no CDN dependency.


The Parser Is the Product

The hardest part of this project wasn't Supabase or Telegram. It was making the parser genuinely robust.

"spent 500 on ola" is easy. But real people type things like:

  • 1.5k myntra shirt (k = thousands)
  • 2l rent paid (l = lakhs, Indian notation)
  • rs 500 groceries (currency prefix)
  • got salary 75k (income, not expense)
  • ola 250 last night (amount anywhere in the sentence)
  • coffee 3 (small amounts without a unit)

The parser handles all of these. It finds amounts using regex that understands Indian notation, classifies the type as expense or income based on trigger words ("salary", "credited", "refund", "cashback"), and assigns a category by matching the note against keyword sets.

"spent 500 on ola"          {amount: 500,  category: "travel",      type: "expense"}
"swiggy 420 dinner"         {amount: 420,  category: "food",        type: "expense"}
"got salary 75000"          {amount: 75000, category: "income",     type: "income"}
"1.5k myntra shirt"         {amount: 1500, category: "clothes",     type: "expense"}
"2l rent paid"              {amount: 200000, category: "rent",      type: "expense"}
Enter fullscreen mode Exit fullscreen mode

91 test cases cover the parser alone, including adversarial inputs: a message like "call me at 7pm" should return null, not {amount: 7}. Getting that boundary right took more iteration than any other part of the system.


Per-User Isolation From Day One

Most personal bots are built for one person. This one is built to handle many people using the same bot — each completely isolated from the other.

Every transaction carries a chat_id. Every query is scoped to the sender. Nobody sees anyone else's spending. And the dashboard — which has no login — shows exactly one owner's data, set in config via a single field: owner_chat_id.

{
  "owner_chat_id": 123456789
}
Enter fullscreen mode Exit fullscreen mode

Not sure what your chat id is? /whoami tells you. It even detects the single-user case automatically — if only one person has ever messaged the bot, it adopts them as the owner without requiring any configuration at all.

Per-user budgets extend this. You can set category caps without touching a config file:

/setcap food 8000
/setbudget 50000
Enter fullscreen mode Exit fullscreen mode

Your caps layer over the defaults. Setting one doesn't wipe the others. And when the dashboard regenerates, it reads your caps — not the install defaults — so the budget bars actually reflect what you told it.


The Projection That Doesn't Lie to You

Most budget apps project your monthly spend by multiplying today's daily average by 30. This sounds reasonable until you pay rent on the 1st and the app tells you you're on track to spend ₹6,00,000 this month.

Ek Ek Paisa ka Hisab flags recurring costs before extrapolating. Rent, EMIs, subscriptions — large one-time payments that appear in history at roughly monthly intervals — get pulled out before the daily rate is calculated. What's left is genuine variable spending: food, travel, coffee. That gets projected. Rent doesn't.

The result is a projection that feels honest. If it says you'll overshoot by ₹3,000 on food by the end of the month, it probably means it.


The Health Check That Started a Security Story

I built a /health route because I wanted to point an uptime monitor at the bot. GET /health/live for a liveness probe, /health/ready if you want it to actually hit Postgres first. Standard stuff.

But building it forced me to think about what a health endpoint is actually exposing. It knows your database URL. It knows whether your bot token is set. And Postgres error messages — as I found out the hard way — will sometimes quote your connection string back at you when they fail.

So the health endpoint reports credentials as shapes, not values:

{
  "supabase_key": "sb_secret_…(41)",
  "telegram_token": "set",
  "supabase_project": "hfmm…yc"
}
Enter fullscreen mode Exit fullscreen mode

It doesn't use SimpleHTTPRequestHandler, which would cheerfully serve the .env file sitting beside it. It answers exactly three string paths and has no concept of the filesystem. And report() runs a final sweep over the assembled JSON to catch any chat id that snuck into an error message — because Telegram chat ids are personal data and the dashboard already works hard to keep them out of the browser.

There are 19 traversal attack attempts in the test suite: /../.env, /%2e%2e%2f.env, /health/../.env, /../../../../etc/passwd. All of them 404.

Building the health check right taught me something: every surface that reports on a system is also a potential leak of that system. Write it accordingly.


A Lesson in What Gets Committed

Here's the part I'd rather not write, but honesty is the point of this kind of article.

The original config.json held all three credentials — the Telegram token, the Supabase URL, and the secret key. When I pushed the first commit to a public GitHub repo, all three went with it. The repo was indexed within minutes.

The fix was moving every credential into .env (gitignored) and making config.json settings-only. But the better fix was making the code refuse to start if a secret finds its way back into the file:

stale = [k for k in ("telegram_token", "supabase_key")
         if (cfg.get(k) or "").strip() not in _PLACEHOLDERS]
if stale:
    raise ConfigError(
        f"{', '.join(stale)} still have a real value in config.json.\n"
        "  Secrets belong in .env now, which is gitignored."
    )
Enter fullscreen mode Exit fullscreen mode

A leak that becomes a startup error instead of a silent commit is a much better outcome. Pair that with a test that asserts no credential ever reaches data.js, and you've got defense in depth rather than "I promise I'll remember."

The .env loader is thirty lines of vanilla Python — no python-dotenv, no extra install. It handles comments, export prefixes, quoted values, and a # inside a quoted key (Supabase keys can legitimately contain one). 12 parser tests for that loader alone, including the edge case that makes most hand-rolled parsers fail.


What's Actually Running

The stack is:

  • Python + python-telegram-bot v22 — async handlers, asyncio.to_thread for blocking I/O
  • Supabase (Postgres + PostgREST) — free tier handles the volume comfortably
  • No server for the dashboard — static HTML + data.js, served from file://
  • 323 automated checks — parser, storage, dashboard DOM, live handler runs, health probes

The test that's most worth reading is tests/live_two_users.py. It drives the actual bot handlers with fake Update objects — the same way python-telegram-bot would — and prints every reply. Unit tests passed on this codebase while the bot was projecting spend off by 6x. The live test caught it. Reading the words your bot actually says is irreplaceable.


What I'd Do Differently

I'd design for secret rotation from the start. Not "I'll add that later." The gap between "later" and "that commit is on GitHub" is shorter than you think.

I'd run the live integration test before shipping any non-trivial change. The unit tests give you fast, reliable feedback. The live test tells you what the user actually reads. Both matter; they answer different questions.

I'd set owner_chat_id on day one. An ambiguous owner means no dashboard, and that failure mode is confusing the first time you hit it.


Get the Code — and Try It Live

Try the bot right now: @PennyTrak_bot on Telegram. Just send it a message like spent 500 on coffee and watch it work.

The full project — bot, parser, dashboard, health endpoint, all 323 tests — is on GitHub. The only thing you need to self-host is a Telegram bot from @botfather and a free Supabase project. Total monthly cost: ₹0.

git clone https://github.com/mudirajesh/....
cd Ek-Paisa-ka-Hisab
cp .env.example .env
# fill in TELEGRAM_TOKEN, SUPABASE_URL, SUPABASE_KEY
pip install python-telegram-bot supabase
python bot.py
Enter fullscreen mode Exit fullscreen mode

Then text your bot. Your first entry takes about four seconds end-to-end — message received, parsed, stored, data.js regenerated. Open dashboard.html. That's it.


If you found this useful, the clap button keeps these going. Questions or pull requests welcome.


Tags: Python · Telegram Bot · Personal Finance · Supabase · Side Projects · Open Source

Top comments (0)