A simple personal expense tracker for Indonesia using a Telegram bot.
Send an expense message through Telegram, and the bot parses it with a deterministic Regex-based parser and saves it to Google Sheets.
No AI is required.
- Telegram Bot API integration
- Deterministic expense parsing with Regex
- IDR amount parsing
- Supports
k,rb, andribuunits - Strict expense categories
- Optional historical date and time
- Google Sheets storage
- Persistent Telegram webhook duplicate protection
- Jakarta timezone (
Asia/Jakarta) - Flask webhook backend
- Deployable on PythonAnywhere
- Python 3.13+
- Flask
- Gunicorn
- Telegram Bot API
- Google Sheets API
gspreadgoogle-authpython-dotenvrequests
The basic format is:
<amount> <category> <description>
25k makan nasi goreng
50rb transportasi bensin motor
150000 personal kaos
The following units are supported:
krbribu
They are case-insensitive and represent ×1,000.
Examples:
25k → Rp25,000
25rb → Rp25,000
25ribu → Rp25,000
Only these categories are accepted:
| Category | Purpose |
|---|---|
makan |
Food, drinks, groceries, coffee |
transportasi |
Fuel, toll, parking, Gojek/Grab |
tagihan |
Utilities, electricity, WiFi, phone credit |
keluarga |
Allowances, family needs, house needs |
donasi |
Charity, zakat, infaq, social giving |
personal |
Hobbies, skincare, clothes, gadgets |
lainnya |
Anything else |
Category matching is case-insensitive.
Expenses can optionally specify a date:
25k makan nasi goreng @2026-09-25
Or an exact date and time:
25k makan nasi goreng @2026-09-25 19:30
If no date or time is provided, the current Jakarta time is used.
Natural-language dates are intentionally not supported.
The bot stores expenses in Sheet1.
| Column | Name | Description |
|---|---|---|
| A | Timestamp | YYYY-MM-DD HH:MM:SS |
| B | Day | Day of month |
| C | Month | Jan, Feb, Mar, etc. |
| D | Year | Four-digit year |
| E | Day_of_Week | Monday, Tuesday, etc. |
| F | Raw_Text | Original Telegram message |
| G | Item | Parsed description |
| H | Amount_IDR | Amount in IDR |
| I | Category | Normalized category |
| J | Telegram_Update_ID | Telegram update identifier |
Column J is used for webhook idempotency.
Telegram may retry a webhook update if it does not receive a successful response.
The bot stores the Telegram update_id with each expense and checks it before inserting a new row.
This prevents the same Telegram message from creating multiple expenses.
telegram-expense-tracker/
├── main.py
├── Procfile
├── requirements.txt
├── runtime.txt
├── README.md
└── .gitignore
Sensitive local files:
.env
credentials.json
must not be committed to Git.
The application requires:
TELEGRAM_BOT_TOKEN
SPREADSHEET_ID
GOOGLE_CREDENTIALS_JSON
Example .env:
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
SPREADSHEET_ID=your_google_spreadsheet_id
GOOGLE_CREDENTIALS_JSON={"type":"service_account",...}Never commit .env or credentials.json.
The Google service account requires access to:
- Google Sheets API
- Google Drive API
The target Google Spreadsheet must be shared with the service account email.
The application uses these OAuth scopes:
https://www.googleapis.com/auth/spreadsheets
https://www.googleapis.com/auth/drive
Install dependencies:
pip install -r requirements.txtStart the Flask application:
python main.pyThe application runs on:
http://localhost:5000
Health check:
curl http://localhost:5000/healthExpected response:
{
"status": "ok"
}The production Telegram webhook points to:
https://YOUR_DOMAIN/telegram-webhook
Set the webhook using:
curl -X POST \
"https://api.telegram.org/botYOUR_BOT_TOKEN/setWebhook" \
-d "url=https://YOUR_DOMAIN/telegram-webhook"Check the webhook:
curl \
"https://api.telegram.org/botYOUR_BOT_TOKEN/getWebhookInfo"The application can be deployed as a Python web application.
The WSGI application imports:
from main import app as applicationThe project uses:
web: gunicorn main:app
as its process command.
The production environment must provide:
TELEGRAM_BOT_TOKEN
SPREADSHEET_ID
GOOGLE_CREDENTIALS_JSON
Successful expense:
✅ Added: Rp25,000
Category: makan
Item: Nasi goreng
Invalid format:
⚠️ Invalid format. Use: <amount> <category> <description>
Example: 25k makan nasi goreng
Invalid category:
❌ Category 'bensin' is invalid.
Allowed categories:
• donasi
• keluarga
• lainnya
• makan
• personal
• tagihan
• transportasi
The application intentionally does not use an AI model.
A valid expense must follow the defined syntax.
This makes expense recording predictable and reduces unexpected categorization.
Only predefined categories are accepted.
This keeps the Google Sheet consistent and easier to analyze later.
Telegram webhook retries must not create duplicate expenses.
The Telegram update_id is stored in Google Sheets and checked before inserting an expense.
All generated timestamps use:
Asia/Jakarta
instead of relying on the hosting server's timezone.
Personal project.