Building a family budget bot on $0: Telegram, free-tier AI fallbacks, and Arabic RTL
For four years I tracked my family's spending in an Excel sheet. It died the way most budget sheets die: logging a purchase took too long, and nobody but me ever opened the file. Mizan is what I built to replace it. You
For four years I tracked my family's spending in an Excel sheet. It died the way most budget sheets die: logging a purchase took too long, and nobody but me ever opened the file.
Mizan is what I built to replace it. You message a Telegram bot ("bread 50", a voice note, a photo of a receipt), it saves the entry, and each morning it tells the household how much it can spend today. This post covers the decisions behind it. The code is private, so you'll get architecture and trade-offs rather than a repo link.
The constraint: $0, and nothing sleeps
I set one rule before writing code: every service must fit a free tier, with no long-running server that sleeps on idle. A Telegram webhook that takes 30 seconds to wake up feels broken.
That ruled out NestJS on a free container host. The stack I ended up with:
| Concern | Choice |
|---|---|
| Web + API | Next.js 16 App Router, one deployment |
| API layer | Hono mounted at /api/v1/*
|
| Database + auth | Supabase Postgres and Auth |
| ORM | Drizzle |
| Hosting | Netlify free tier through OpenNext |
| Cron | GitHub Actions calling endpoints guarded by a secret |
| AI | Gemini and Groq free tiers via the Vercel AI SDK |
Hono inside a Next.js catch-all route gave me typed routes, middleware, and an OpenAPI spec for a future mobile client, while keeping one deploy.
Layers, and why authorization lives in services
Routes and Server Actions stay thin. They validate input with zod and call a service. Services hold the business rules. Repositories hold Drizzle queries and nothing else.
Drizzle connects as a role that bypasses Postgres row-level security, so RLS can't be the guard. Every service function that touches household data calls requireMembership or requireAdmin first. RLS stays enabled with zero policies, which blocks the public Supabase API from reading anything at all.
The webhook
Telegram retries an update if you don't answer fast. The handler does four things in order:
- Checks the secret header Telegram sends.
- Dedupes on
update_id. - Returns 200.
- Does the real work inside Next.js
after().
Step 4 matters on serverless. A slow model call can't make Telegram resend the message and create a duplicate entry.
Never drop a message
The rule I care about most: a message the bot can't parse still gets saved, to an inbox table, and the sender still gets a reply. Every entry the bot creates carries an undo button. A wrong entry that saves without a word does more damage to trust than a parse failure.
AI on free tiers means fallbacks
Free quotas run out, and models get busy. Each AI call goes through one provider interface that tries a chain:
-
Text and receipts: Gemini Flash-Lite, then Gemini Flash, then Groq
gpt-oss-120bandgpt-oss-20b. -
Voice: Groq
whisper-large-v3, then Gladia, then Gemini with audio input. All three accept Telegram's.oggas is.
zod validates every model output against the same schema the API uses, and an ai_usage table counts each call.
The bigger win came from skipping the model. "bread 50", "how much is left?", and an item the household has logged before all resolve locally with no model call. When someone corrects a category, Mizan remembers that title for next time. Most daily messages never touch Gemini.
A test set of 95 real messages (Arabic, English, Turkish, mixed currencies, shopping-list lines) runs against the live model on demand and fails below 90% accuracy.
Money is decimal, and it travels with its currency
The household holds Syrian pounds, dollars, and sometimes euros. Amounts sit in Postgres numeric, a decimal-safe helper does the arithmetic, and no float ever touches a price. Each amount in code, API, and UI carries a currency_code. The server computes the base-currency amount from the saved exchange rate; clients never send it.
Dates follow the same discipline. A transaction's date comes from the household's timezone, never the server clock. An entry at 11 pm in Damascus belongs to that day, not the next one in UTC.
Arabic first
Arabic is the default and renders right to left. Tailwind's logical utilities (ms-*, pe-*, start-*) replace every ml-* and left-*, so one layout works both ways. Charts flip their time axis in RTL, so the cycle's first day sits on the right. Arabic-Indic digits typed on the keypad parse like Western ones. English and Turkish come through next-intl with no URL prefix inside the app.
Offline
Mizan installs as a PWA with Serwist. The main screens keep a copy, and an entry logged with no connection waits in IndexedDB and syncs once with an idempotency key, so a flaky connection never saves it twice.
Testing against the real thing
Unit tests in Vitest cover cycle math, currency conversion, the daily number, and membership checks. The rest runs as 41 end-to-end scripts that drive headless Edge against the dev server. Each creates a throwaway user through the Supabase admin API, runs a feature end to end, screenshots it in both languages and themes, and deletes the user. The Telegram scripts start a mock Telegram server, so no real message goes out.
What I'd tell you if you build something like this
- Write the rules down before the code. My repo has a decisions log with close to 90 entries, and it settled arguments with myself more than once.
- Put the feature where your users already are. My family wouldn't open a dashboard. They send voice notes all day.
- Treat free tiers as a design input. The fallback chains and the no-model shortcuts exist because of quotas, and they made the bot faster too.
Mizan is live and free for any household: https://mizans.netlify.app. Questions about any of the above, ask in the comments.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.