Dev.to AI 🤖 Ai 👁 0 📖 4 min read

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:

  1. Checks the secret header Telegram sends.
  2. Dedupes on update_id.
  3. Returns 200.
  4. 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-120b and gpt-oss-20b.
  • Voice: Groq whisper-large-v3, then Gladia, then Gemini with audio input. All three accept Telegram's .ogg as 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.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.