DEV Community

Archit Mittal
Archit Mittal

Posted on • Originally published at architmittal.com

Build a Telegram Expense Tracker Bot in 58 Lines of Python

Every expense tracking app I have tried dies the same death. You install it, you log things diligently for nine days, and then you are standing outside a chai stall with ₹20 in change and no intention of opening an app, tapping "Add Expense", picking a category from a dropdown, and hitting save.

The friction is the whole problem. So the fix is not a better app — it is a smaller one.

This is a Telegram bot in 58 lines of Python. You message it 450 lunch. It replies Logged Rs 450.00 - lunch. That is the entire interaction. Telegram is already on your phone, already open, and typing four characters into a chat you already have pinned costs you nothing.

No framework, no database, no hosting bill. One dependency: requests.

Why Telegram instead of a real app

A Telegram bot gives you, for free, things that would otherwise take weeks:

  • A cross-platform client that already works on your phone, laptop, tablet, and watch
  • Authentication — Telegram tells you which chat_id sent the message, so multi-user separation is one field
  • Push notifications, message history, and search
  • A stable HTTP API you can drive with requests and nothing else

You write the parsing and the arithmetic. Telegram handles being an app.

Get a token

Message @BotFather on Telegram, send /newbot, pick a name. It hands you a token that looks like 8012345678:AAF.... Put it in your environment — never in the file:

export TELEGRAM_TOKEN="8012345678:AAF..."
pip install requests
Enter fullscreen mode Exit fullscreen mode

The bot

import csv, os, re, time, requests
from collections import defaultdict
from datetime import datetime

TOKEN = os.environ["TELEGRAM_TOKEN"]
API = f"https://api.telegram.org/bot{TOKEN}"
LEDGER = "expenses.csv"
ENTRY = re.compile(r"^(?:rs\.?|inr|₹)?\s*(\d+(?:\.\d{1,2})?)\s+(.{1,40}?)$", re.I)
HELP = "Send: <amount> <note>\ne.g.  450 lunch  |  ₹1200 groceries\nCommands: /total [YYYY-MM]"


def send(chat_id, text):
    requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": text}, timeout=20)


def log_expense(chat_id, amount, note):
    fresh = not os.path.exists(LEDGER)
    with open(LEDGER, "a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        if fresh:
            w.writerow(["date", "chat_id", "amount", "note"])
        w.writerow([datetime.now().strftime("%Y-%m-%d"), chat_id, f"{amount:.2f}", note])


def month_report(chat_id, month=None):
    month = month or datetime.now().strftime("%Y-%m")
    totals = defaultdict(float)
    if os.path.exists(LEDGER):
        with open(LEDGER, encoding="utf-8") as f:
            for row in csv.DictReader(f):
                if row["chat_id"] == str(chat_id) and row["date"].startswith(month):
                    totals[row["note"].lower()] += float(row["amount"])
    if not totals:
        return f"Nothing logged for {month} yet."
    rows = sorted(totals.items(), key=lambda kv: -kv[1])
    out = [f"Spend for {month}", "-" * 30]
    out += [f"{note[:17]:<17} Rs {amt:>9,.2f}" for note, amt in rows]
    out += ["-" * 30, f"{'TOTAL':<17} Rs {sum(totals.values()):>9,.2f}"]
    return "\n".join(out)


def handle(message):
    chat_id = message["chat"]["id"]
    text = (message.get("text") or "").strip()
    if text.startswith("/total"):
        parts = text.split(maxsplit=1)
        return send(chat_id, month_report(chat_id, parts[1].strip() if len(parts) > 1 else None))
    match = ENTRY.match(text)
    if not match:
        return send(chat_id, HELP)
    amount, note = float(match.group(1)), match.group(2).strip()
    log_expense(chat_id, amount, note)
    send(chat_id, f"Logged Rs {amount:,.2f} - {note}")


def main():
    offset = 0
    while True:
        try:
            r = requests.get(f"{API}/getUpdates", params={"offset": offset, "timeout": 50}, timeout=60)
            for update in r.json().get("result", []):
                offset = update["update_id"] + 1
                if "message" in update:
                    handle(update["message"])
        except Exception as exc:
            print("poll error:", exc)
            time.sleep(3)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it with python expense_bot.py and message your bot.

The three parts that matter

The regex. ENTRY is doing the entire "natural language" job:

r"^(?:rs\.?|inr|₹)?\s*(\d+(?:\.\d{1,2})?)\s+(.{1,40}?)$"
Enter fullscreen mode Exit fullscreen mode

An optional currency prefix, an amount with at most two decimal places, then everything else as the note. All of these parse:

450 lunch                 -> 450.00, "lunch"
₹1200 groceries           -> 1200.00, "groceries"
Rs 89.50 auto rickshaw    -> 89.50, "auto rickshaw"
INR 2500 electricity bill -> 2500.00, "electricity bill"
Enter fullscreen mode Exit fullscreen mode

The {1,40} bound on the note is deliberate. Without it, a pasted paragraph that happens to start with a number becomes a category. Anything that does not match falls through to the help text, so the bot never silently swallows a message.

The offset. This is the part people get wrong. Telegram's getUpdates keeps returning the same updates until you acknowledge them. You acknowledge by passing offset = last_update_id + 1 on the next call. Forget it and your bot cheerfully logs the same ₹450 lunch in an infinite loop.

timeout=50 in the params is long polling — the request hangs open on Telegram's side until a message arrives or fifty seconds pass. That means near-instant replies without hammering the API. The outer timeout=60 on requests has to be larger than the inner one, or requests gives up while Telegram is still politely holding the line.

The chat_id filter. month_report only sums rows matching the requesting chat_id. Add the bot to a group, or share it with your spouse, and everyone gets their own ledger out of one CSV. It is a one-line multi-tenancy story.

What it looks like in use

You:  450 lunch
Bot:  Logged Rs 450.00 - lunch
You:  ₹1200 groceries
Bot:  Logged Rs 1,200.00 - groceries
You:  /total
Bot:  Spend for 2026-08
      ------------------------------
      electricity bill  Rs  2,500.00
      groceries         Rs  1,200.00
      lunch             Rs    750.00
      auto rickshaw     Rs     89.50
      ------------------------------
      TOTAL             Rs  4,539.50
Enter fullscreen mode Exit fullscreen mode

Why CSV and not SQLite

Because the data is tiny and you will want to open it in Excel.

A year of aggressive logging is maybe four thousand rows. Reading that file end to end to answer /total takes single-digit milliseconds. When your accountant asks for your expenses, you send them the file. When you want a chart, you open it in a spreadsheet. SQLite buys you nothing here except a step between you and your own data.

If you ever do outgrow it, month_report is the only function that reads the ledger. Swap its body and everything else is untouched.

Extensions worth twenty more lines

  • /undo — pop the last row for that chat_id. Easiest and most-used addition.
  • Category mapping — a dict from keyword to bucket, so chai, lunch, and dinner all roll up to food.
  • Monthly auto-report — a cron job on the 1st that calls send() with last month's report. No polling needed, just import the module.
  • Budget alerts — after log_expense, compare the running category total against a cap and warn on the same reply.

Keeping it running

On a laptop it runs as long as the terminal is open, which is fine for a week of testing. For always-on, the cheapest honest answer is a systemd unit on any small VPS:

[Unit]
Description=Expense Bot
After=network.target

[Service]
Environment=TELEGRAM_TOKEN=8012345678:AAF...
WorkingDirectory=/opt/expense-bot
ExecStart=/usr/bin/python3 /opt/expense-bot/expense_bot.py
Restart=always

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Then systemctl enable --now expense-bot. The Restart=always plus the try/except in main() means a dropped connection or a Telegram hiccup costs you three seconds, not your afternoon.

The actual point

The reason this works when apps do not is that it removes every decision. There is no category dropdown, no date picker, no "which account". You type a number and a word into a chat window you were already looking at.

Fifty-eight lines is not a limitation here. It is the feature.


Follow me on Twitter @automate_archit for daily AI automation tips.

Top comments (0)