A personal finance tracker for family use (2 users). Send your spending to a
Telegram bot as casual text (makan siang 50rb, gaji 5jt) or a photo
of a receipt / bank-transfer / e-wallet screenshot. A Go bot OCRs images, asks
Gemini to structure the text into transaction(s), saves them to Postgres
(Supabase), and a React dashboard visualizes everything.
Telegram message ββΆ Go bot ββΆ (OCR if image) ββΆ Gemini (structured JSON)
β
βΌ
Postgres (Supabase)
β
βΌ
React dashboard (reads via RLS)
apps/
bot/ # Go: Telegram bot + OCR + Gemini + Postgres
cmd/bot/ # entrypoint
internal/
config/ # typed config from env (.env via godotenv)
domain/ # Transaction, Category/Type enums, validation
ocr/ # gosseract (Tesseract) wrapper: bytes -> text
ai/ # provider-agnostic Parser: text -> []Transaction (gemini | openai)
store/ # sqlc-generated queries + pgx pool wrapper
telegram/ # text / photo / image-document handlers
db/queries/ # sqlc query definitions
dashboard/ # React 19 + Vite + TanStack Router/Query + Tailwind v4 + Recharts
supabase/
config.toml # Supabase CLI project config
migrations/ # SQL migrations (schema source of truth, applied via `supabase db push`)
functions/
telegram-login/ # Edge Function: verifies Telegram login β Supabase session
packages/
config/ # shared tsconfig + eslint for the JS side
turbo.json # build / dev / lint pipeline
pnpm-workspace.yaml
Put all of these in a .env file at the repo root (copy from .env.example):
| Variable | Where to get it |
|---|---|
TELEGRAM_BOT_TOKEN |
Message @BotFather on Telegram β /newbot β copy the token. |
AI_PROVIDER / AI_MODEL / AI_API_KEY |
The LLM provider (gemini or openai-compatible), model id, and key. See Choosing an AI provider. |
DATABASE_URL |
Supabase β create a project β Project Settings β Database β Connection string (URI). Include ?sslmode=require. |
VITE_SUPABASE_URL |
Supabase β Project Settings β API β Project URL. |
VITE_SUPABASE_PUBLISHABLE_KEY |
Supabase β Project Settings β API Keys β publishable key (sb_publishable_β¦). Replaces the legacy anon key. |
VITE_TELEGRAM_BOT_USERNAME |
Your bot's username (without @) from BotFather β the login screen links to it so you can DM /login. |
TESSDATA_PREFIX |
Path to Tesseract trained data (see install step). macOS Homebrew: /opt/homebrew/share/tessdata. |
ALLOWED_TELEGRAM_IDS |
Optional. Comma-separated Telegram user IDs allowed to use the bot. Empty = any linked account. |
No hardcoded user IDs. Access is granted by signing into the dashboard with Telegram (see Login (Sign in with Telegram)). The bot writes to Postgres directly with
DATABASE_URL(bypasses RLS); the dashboard reads with the publishable key gated by Row Level Security.
- Go 1.24+ β
brew install go - pnpm + Node 20+ β
brew install pnpm node - Tesseract with Indonesian + English trained data (for local OCR):
- macOS:
brew install tesseract tesseract-lang(installsind/enginto/opt/homebrew/share/tessdata) - Debian/Ubuntu:
sudo apt-get install tesseract-ocr tesseract-ocr-ind tesseract-ocr-eng libtesseract-dev libleptonica-dev
- macOS:
- sqlc (generate DB code) β
brew install sqlc - Supabase CLI (migrations + local stack) β
brew install supabase/tap/supabase - Docker (for the local Supabase stack used by
pnpm dev) β Docker Desktop / OrbStack / colima
The bot uses CGO to link Tesseract. On macOS Homebrew, Leptonica is keg-only; the bot's npm scripts (
apps/bot/scripts/go.sh) set the rightCGO_*paths automatically, sopnpm dev/turbo buildjust work. On Linux the apt headers are already on the default path.
# 1. Install JS deps (Turbo, dashboard deps, shared config)
pnpm install
# 2. Configure
cp .env.example .env
$EDITOR .env # fill in the keys from the table above
# 3. Schema.
# LOCAL DEV: skip this β `pnpm dev` boots a local Supabase stack and applies
# supabase/migrations automatically.
# HOSTED (shared/prod) project: link it once and push the migrations.
# Find <project-ref> in your project's URL or Project Settings β General.
supabase login # one-time, opens a browser
supabase link --project-ref <project-ref>
supabase db push # applies supabase/migrations/* (= pnpm db:push)
# 4. (Re)generate type-safe DB code from db/queries β only needed if you change
# the schema (supabase/migrations) or queries; generated code is committed.
cd apps/bot && sqlc generate && cd -
# β¦or: pnpm --filter @kitacatat/bot run sqlc
# To add a new migration later: pnpm db:new <name> (then edit the file,
# then pnpm db:push). To iterate locally with Docker: supabase db reset.# Local dev: boots the Dockerized Supabase stack, points both apps at it,
# then runs bot + dashboard. (Requires Docker running.)
pnpm dev
# Run only the apps against whatever your env points to (e.g. a hosted
# Supabase) without starting the local stack:
pnpm dev:apps
# Or individually:
pnpm --filter @kitacatat/bot run dev # starts the Telegram bot
pnpm --filter @kitacatat/dashboard run dev # Vite dev server (http://localhost:5173)What pnpm dev does: runs supabase start (applies supabase/migrations
to a local Postgres), then exports the local stack's connection details onto
DATABASE_URL / VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY before launching
Turbo. These exported vars override .env, so locally your .env only needs the
non-Supabase secrets: TELEGRAM_BOT_TOKEN, GEMINI_API_KEY,
VITE_TELEGRAM_BOT_USERNAME, and TESSDATA_PREFIX.
Handy local URLs (from supabase start): Studio (DB UI) at
http://127.0.0.1:54323. Stop the stack with pnpm supabase:stop.
Auth is Telegram-native via a bot-issued login code β no email, no widget,
no HTTPS domain required (so it works on localhost):
- You DM
/loginto the bot. - The bot (Telegram has already authenticated you) writes a short-lived,
single-use token to
login_tokensand replies with a dashboard link ($DASHBOARD_URL/?token=β¦). - Opening the link makes the dashboard POST the token to the
telegram-loginEdge Function, which validates it, provisions a Supabase user whose profile carries thetelegram_id, and returns a one-time OTP the dashboard exchanges for a session.
Logging in is the registration, and it's what authorizes the bot. Optionally
restrict the bot to specific Telegram accounts with ALLOWED_TELEGRAM_IDS
(comma-separated IDs) β every bot handler is gated on that allowlist, so
unlisted users are blocked before they can even /login. Without it, any
linked account may use the bot. Registration is capped at MAX_PROFILES
(default 2) accounts; returning users always get in. The Supabase session
(access + refresh) is then managed by Supabase as usual.
One-time setup: deploy the Edge Function (no bot domain needed):
supabase functions deploy telegram-login # config sets verify_jwt=false
# optional: change the account cap (default 2)
# supabase secrets set MAX_PROFILES=2SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically. Set
DASHBOARD_URL for the bot (defaults to http://localhost:5173) so the login
link points at the right place.
Then DM your bot on Telegram:
makan siang 50rbβ 1 expense, category foodgaji 5jtβ 1 income, category salary- a photo of a receipt β OCR β the total is captured (add a caption for extra context, e.g. "belanja bulanan")
- an image sent as a file (document) β handled the same way
pnpm build # = turbo run build
# bot -> apps/bot/bin/bot
# dashboard -> apps/dashboard/distcd apps/bot
docker build -t kitacatat-bot .
docker run --rm --env-file ../../.env kitacatat-botThe image is multi-stage: it builds with the Tesseract/Leptonica dev headers
(CGO_ENABLED=1) and ships a slim runtime that installs the Tesseract runtime
plus tesseract-ocr-ind + tesseract-ocr-eng and sets TESSDATA_PREFIX.
Three targets, each deployed from GitHub Actions in .github/workflows/ (with
path filters so only the changed app redeploys):
| Piece | Host | Workflow |
|---|---|---|
| Bot (Go, always-on) | Your VPS (Docker) | bot.yml β build β push to GHCR β SSH pull & restart |
| Dashboard (Vite SPA) | Cloudflare Pages | dashboard.yml β build + wrangler pages deploy |
| DB + Edge Function | Supabase | supabase.yml β db push + functions deploy |
| PR checks | β | ci.yml β pnpm build + pnpm lint (installs Tesseract for the Go build) |
The bot long-polls Telegram (outbound only), so the VPS needs no domain, no open ports, no HTTPS β just Docker and outbound internet.
- VPS (Debian/Ubuntu): install Docker, then create the runtime env file the
container reads (secrets stay on the box, not in GitHub):
(
curl -fsSL https://get.docker.com | sh sudo mkdir -p /opt/kitacatat sudo tee /opt/kitacatat/.env >/dev/null <<'EOF' TELEGRAM_BOT_TOKEN=β¦ DATABASE_URL=β¦ # your Supabase connection string DASHBOARD_URL=https://<your-pages-domain> AI_PROVIDER=openai AI_BASE_URL=https://api.groq.com/openai/v1 AI_MODEL=llama-3.3-70b-versatile AI_API_KEY=β¦ EOF
TESSDATA_PREFIXis already set inside the image.) Add the public key whose private half you'll put inSSH_KEYto~/.ssh/authorized_keys. - Cloudflare Pages: create a project named
kitacatat(wrangler pages project create kitacatat). - Supabase: have a hosted project (its
<project-ref>).
| Secret | Used by |
|---|---|
SSH_HOST, SSH_USER, SSH_KEY (private key), SSH_PORT (optional) |
bot.yml (deploy over SSH) |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID |
dashboard.yml |
VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_TELEGRAM_BOT_USERNAME |
dashboard.yml (build-time) |
SUPABASE_ACCESS_TOKEN, SUPABASE_PROJECT_REF, SUPABASE_DB_PASSWORD |
supabase.yml |
The image is pushed to GHCR using the built-in
GITHUB_TOKEN(no secret needed). The bot's runtime env lives in/opt/kitacatat/.envon the VPS, not in GitHub.
After secrets are set, every push to main deploys the pieces it touched.
The migrations in supabase/migrations/ create:
profilesβ one row per Supabase Auth user (auto-created by a trigger onauth.users), with a uniquetelegram_id+telegram_username. These are set by thetelegram-loginEdge Function when the user signs in with Telegram.transactions.user_idis auuidFK toprofiles(id)β the bot looks up the profile bytelegram_idand stamps each transaction with it.
RLS (all policies scoped with TO authenticated):
profiles: a user can only read/update their own row (auth.uid() = id; the update policy also has a matchingWITH CHECK).transactions: any logged-in family member can read all rows (USING (true)) β a shared household view. To isolate per user instead, change the policy toUSING ((select auth.uid()) = user_id)(noted inline in the migration).- The bot writes via the direct Postgres connection, which bypasses RLS, so
there are no write policies/grants for
anon/authenticated.
internal/ai is provider-agnostic β a Parser interface with two
implementations, selected by env:
AI_PROVIDER=gemini(gemini.go) β Google's native SDK with a strict response schema. Default modelgemini-2.5-flash.AI_PROVIDER=openai(openai.go) β any OpenAI-compatible/chat/completionsendpoint. Switch providers by just settingAI_BASE_URL+AI_MODEL+AI_API_KEYβ no code changes. Works with OpenAI, Groq, OpenRouter, DeepSeek, Together, local Ollama, β¦
This app's calls are tiny (a short message or OCR text in, small JSON out), so cost is fractions of a cent per message β pick for reliability, not price:
| Want | Set |
|---|---|
| Cheapest reliable Gemini | AI_PROVIDER=gemini, AI_MODEL=gemini-2.5-flash-lite (enable billing to avoid free-tier "high demand" 503s) |
| Free + fast | AI_PROVIDER=openai, AI_BASE_URL=https://api.groq.com/openai/v1, a Groq Llama model |
| One key, many models | AI_PROVIDER=openai, AI_BASE_URL=https://openrouter.ai/api/v1, an OpenRouter model |
gemini-2.0-flashis retired (0 free quota) β don't use it.
internal/ai sends the text (plus any photo caption) to the configured model in
JSON mode. The model is told that amounts are Indonesian Rupiah (Rp50.000,
50.000, 50rb, 5jt, 1.250.000); to extract one transaction per line item
on an itemized receipt (each with its own AI-inferred category, skipping the
grand total to avoid double-counting) or a single transaction for a plain
note/total; to use a date from the text (else now), and to infer type and
category. The
Gemini provider additionally enforces a strict response schema. Every field is
then validated in Go (amount > 0, type/category in the allowed sets)
and invalid items are dropped before saving.