FinanceTgApp
README.md
FinanceTgApp
A Telegram Mini App for joint financial tracking for two people. SQLite is the source of truth, Google Sheets is a two-way mirror. The entire app consists of two containers and ~200 MB of memory.
Architecture rationale and analysis of alternatives: docs/00-research-and-stack.md. How to manage a joint budget, where the “who owes whom” information comes from, and why a joint account is needed: docs/finance.md.
What it looks like
| Add an expense | History | Report |
|---|---|---|
![]() |
![]() |
![]() |
| Select a category with two taps: first the category, then the subcategory | Grouped by day, daily total, who entered it | The parent category shows the total along with its subcategories |
| Edit entry | Category Directory | Reminders and calculations |
|---|---|---|
![]() |
![]() |
![]() |
| Any field of an existing transaction | Create, rename, change icon | Custom reminder time, who owes whom |
Dark Theme
![]() |
![]() |
![]() |
The color palette is taken from Telegram: the app adopts the client’s theme; there is no separate setting for this.
Demo with a single command
Check it out without any setup—no bot, no tokens, and no Telegram:
make setup # один раз: зависимости бэкенда и фронта
make demo
http://localhost:8000 will open with a pre-populated log: two participants, three months of expenses, about 275 transactions, salaries, transfers to a shared fund, and accumulated debt between them. The data is fixed—the demo looks the same for everyone.
If you don’t want to set up Python and Node:
make demo-docker
The demo runs on a separate database data/demo.db and doesn’t affect the production environment. Access to it is open
without Telegram (DEV_AUTH_BYPASS), so it can’t be exposed externally—only locally.
What’s already working
- Mini App: adding expenses and income in three taps, a history view grouped by day, reports by category and participant, account balances, and mutual settlements.
- Categories and subcategories: “Groceries → Pyaterochka, Magnit.” A two-level tree, with each branch having its own icon and name; editable directly in the app.
- Editing the history: any entry can be opened and edited—amount, date, type, category, account, comment.
- Filters: The transaction history and reports can be filtered by person, transaction type, and category; the selected category automatically includes its subcategories. The “by person” filter answers the question “whose expense was it?” rather than “who recorded it?”
- Bot: Quick single-line entry (
500 пятёрочка),/month,/balance,/settle,/llm,/sync, “change category” and “delete” buttons under each entry. - Reminders: In the evening, the bot sends a message to anyone who hasn’t recorded anything that day. Each person sets their own time in the app, and it’s calculated based on their time zone.
- Accounts: By default, expenses are charged to the author’s personal account. A shared account is set up separately and splits expenses equally—the app calculates who owes whom and accounts for transfers and repayments. Model breakdown—see docs/finance.md.
- Google Sheets: export via the outbox and import of manual edits made in the spreadsheet.
- Export for LLM:
/api/export/llm— compact aggregates instead of raw logs.
Stack
| Layer | Technologies |
|---|---|
| Backend | Python 3.12, FastAPI, aiogram 3, SQLAlchemy 2 + Alembic, APScheduler |
| Database | SQLite (WAL). Migration to Postgres — change DATABASE_URL |
| Frontend | React 18 + TypeScript + Vite, TanStack Query, ~69 KB gzip |
| Infrastructure | Docker Compose: application + Caddy (auto-TLS) |
The bot and API run in the same process: one codebase, one container, shared database transaction.
Quick Start (locally)
cp .env.example .env # заполните BOT_TOKEN и ALLOWED_TELEGRAM_IDS
make setup
make migrate
make api # бэкенд + бот в режиме polling
make web # в другом терминале: фронт на localhost:5173
The Mini App cannot be opened http://localhost within Telegram—you need an HTTPS address.
For local debugging, set up a tunnel and specify it in BotFather:
cloudflared tunnel --url http://localhost:5173
To open the interface in a regular browser without Telegram, set DEV_AUTH_BYPASS=true
and DEV_TELEGRAM_ID=<ваш id>. In production, this flag must be disabled.
Deployment on a VPS
You’ll need Docker with the Compose plugin. On a clean Ubuntu installation:
curl -fsSL https://get.docker.com | sh
Next:
git clone https://github.com/NORMss/FinanceTgApp.git && cd FinanceTgApp
cp .env.example .env && nano .env # BOT_TOKEN, ALLOWED_TELEGRAM_IDS, PUBLIC_URL, DOMAIN, BOT_MODE=webhook, секреты
# Каталог данных: БД и ключ Google. Владелец — uid 10001, под которым работает контейнер
mkdir -p data && sudo chown -R 10001:10001 data
docker compose --profile build run --rm frontend # сборка Mini App в frontend/dist
docker compose up -d --build # приложение + Caddy
docker compose logs -f app
The same thing with a single command— make upif the server has make.
Ports 80 and 443 must be open: without them, Caddy won’t pass the ACME validation and won’t receive the certificate. Migrations are run when the container starts, and the webhook registers itself.
The final step is in @BotFather: /newapp (or Bot Settings → Menu Button) and specify
PUBLIC_URL the Mini App address.
Update:
git pull
docker compose --profile build run --rm frontend
docker compose up -d --build
PUBLIC_URL and DOMAIN specify the same hostname in two formats: the first is needed by the app
(webhook, Mini App button), and the second is needed by Caddy to issue a certificate. .env It must be located
in the root of the repository next to docker-compose.yml: that’s where Compose retrieves it from ${DOMAIN}.
Minimum requirements: 1 vCPU, 1 GB RAM, 10 GB disk space. If the frontend build runs out of memory
on 1 GB, build it locally (make build) and copy it frontend/dist
to the server—the container frontend isn’t needed in that case.
If there’s already a website running on ports 80/443 on the server
, then there’s no need to install Caddy from the package—it won’t be able to use those ports. The application can
serve both the API and the Mini App’s static files on its own, so an external proxy only needs one
proxy_pass:
make up-proxy
or you can set it manually:
docker compose --profile build run --rm frontend
docker compose -f docker-compose.yml -f docker-compose.behind-proxy.yml up -d --build app
The service name app must be included at the end. Without it, Compose will start all services, including
Caddy, and you’ll end up with Bind for 0.0.0.0:80 failed: port is already allocated.
For the same reason, you cannot run make up.
The app listens on 127.0.0.1:8000 (the port is set by the APP_PORT), and it’s
not accessible from the outside—only through your proxy. Next, add a virtual host.
nginx:
server {
listen 443 ssl;
server_name finance.example.com;
# сертификат выпускается вашим обычным способом: certbot --nginx -d finance.example.com
ssl_certificate /etc/letsencrypt/live/finance.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/finance.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
If the external web server is Caddy, the entire configuration boils down to three lines:
finance.example.com {
reverse_proxy 127.0.0.1:8000
}
There’s no need to separate paths between the API and static files: /api/* and /tg/* FastAPI handles it,
everything else is served as Mini App files from frontend/dist.
If the external proxy runs in a container itself
Then 127.0.0.1 this won’t work: for the container, the proxy is its own loopback, not the host.
Instead of port forwarding, connect the application to the proxy network and access it via its DNS name.
Find another project’s network and configure it in .env:
docker network ls
PROXY_NETWORK=имя_сети_прокси
Startup (ports are not published externally at all):
docker compose -f docker-compose.yml -f docker-compose.shared-net.yml up -d --build app
On the proxy network, the application is accessible under the name finance-app. The site block for Caddy—it
will issue the certificate itself, just as it does for its other domains:
finance.example.com {
reverse_proxy finance-app:8000
}
After editing the proxy configuration, you need to restart it:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Backup
The entire configuration is located in the directory data/. Simply copy the entire directory:
docker compose stop app && tar czf backup-$(date +%F).tar.gz data && docker compose start app
A duplicate copy of the log is stored in Google Sheets, if synchronization is enabled.
Google Sheets Setup
- In Google Cloud, create a project and enable the Google Sheets API.
- Create a service account, download the JSON key, and place it in
data/google-credentials.json. - Create a spreadsheet and grant the service account (its email address from the JSON) Editor permissions.
- In
.env:SHEETS_ENABLED=trueandGOOGLE_SPREADSHEET_ID=<id из адреса таблицы>. - Restart the app. The
transactionsand header will be created automatically.
File permissions are granted within the spreadsheet itself, not in the Google Cloud Console: IAM roles do not affect Sheets documents. You can check the entire chain—settings, key, access—with a single command, which specifies the exact reason for failure:
make check-sheets
How it works:
- Each change operation is queued
sync_outboxin the same transaction as the data itself; - a background worker unloads the accumulated data in a single batch once per minute—with 60 requests per minute per user, the quota doesn’t come anywhere close to the limit;
- Google downtime doesn’t break the app: records remain in the queue and will be processed later;
- Manual edits made in the spreadsheet are recognized by the column
sync_hashand are imported back; - You can add a row directly in the spreadsheet—just leave
idit blank, and the app will create a transaction and assign an ID.
Dates in the spreadsheet are stored as text (2026-08-11 14:30), and amounts as numbers. This is done intentionally:
when saving in USER_ENTERED Google would reformat the dates according to the locale, and the import would treat
every row as modified.
Structure
backend/app/
api/ HTTP-слой: роуты, схемы, зависимости
bot/ aiogram: хендлеры, клавиатуры, middleware, исходящие уведомления
models/ SQLAlchemy-модели
repositories/ доступ к данным (без бизнес-логики)
services/ бизнес-логика: журнал, отчёты, напоминания, быстрый ввод, экспорт
sync/ Google Sheets: клиент, маппинг, воркер
security/ проверка initData, сессионные токены
scheduler.py фоновые задания: выгрузка в Sheets и напоминания
frontend/src/
pages/ экраны Mini App
components/ выбор категории, шторка правки, общие блоки
api.ts клиент к бэкенду
categories.ts сборка дерева категорий из плоского списка
telegram.ts обёртка над Telegram WebApp
scripts/ съёмка скриншотов для README
docs/ ресерч по стеку, гид по общему бюджету, скриншоты
Screenshots are updated as follows—you’ll need Chrome installed:
make demo # в одном терминале
cd scripts && npm install && npm run screenshots
Categories
A two-level tree: root (“Products”) and subcategories (“Pyaterochka,” “Magnit,” “KB”). A third level is intentionally disallowed—there’s no place to display it on the phone screen, and in the report it collapses anyway.
- Management: More tab → Categories: create, rename, change icon, add subcategory, hide, delete.
- Icon—an emoji or a pair of letters. Compound emojis (
👨👩👧) do not break up. - In the report, the parent category shows the total along with its subcategories, followed by a breakdown.
- The “Groceries” filter also finds expenses at “Pyaterochka.”
- Deletion requires replacement. The app first shows how many transactions are linked to the category and its subcategories, and asks you to choose where to move them—to an existing category or a new one created on the spot. Without a replacement, only categories with no transactions are deleted. Transactions cannot remain without a category: last month’s report would no longer balance, and there would be no way to restore the breakdown.
- Hiding is not the same as deleting. A hidden category disappears from the selection lists, but the history remains as it was. Deleting overwrites the history: transactions are moved to the replacement category. When the past matters—hide it. Hidden categories are grouped at the bottom of the screen and can be restored with a single button.
- Along with the category, its subcategories are deleted, and the quick-entry rules
(
пятёроч → …) move to the replacement category so that chat input doesn’t get sluggish. - In Google Sheets, the path is written in full:
Продукты · Пятёрочка. You can also do the opposite— manually enter such a row into the table, and the app will create the subcategory on its own.
Before deletion, the app shows the consequences: how many operations are linked to the category, how many subcategories will be deleted along with it, and where everything will be moved.
Accounts and Settlements
- By default, a new entry goes to the author’s personal account—both in the app and in the bot.
- To record a transaction for someone else, select their personal account from the list. The expense will be listed under their name: in their history and in their report column, even though you were the one who entered it. This does not create a debt—it simply splits the total account.
- There is no shared account on a fresh installation. It is created by tapping the button in More → Shared Accounts, provided a shared wallet actually exists. There is exactly one shared account.
- Expenses from the shared account are split equally among the participants and become a debt:
итог = заплатил − своя доля + погашения. The debt is paid off via a transfer from personal → personal. - Income is never split—it changes the balance but not the debt.
- Changing the account associated with a transaction in the history recalculates the shares: if you transferred an expense from the shared account to a personal account, the debt associated with it disappears.
If the debt amount is unclear, ask to see how it was calculated:
make check-settle
The system prints every transaction involved in the calculation, along with the account and shares.
Lines marked ПРОБЛЕМА — these are shares that cannot exist under the current rules; they
remain from those who managed the account in versions prior to August 17, 2026, when shares were recalculated
when the amount or type changed, but not the account. Recalculate them all at once: make fix-settle.
For details, including a numerical example and an analysis of two general budget models, see docs/finance.md.
Reminders
Accounting falls apart not because of a lack of features, but because of a single forgotten evening. That’s why the bot sends a message in the evening to anyone who hasn’t recorded anything that day.
- Time — More → Reminder; each participant has their own, with a toggle switch there as well.
- It’s calculated based on the person’s time zone, not the server’s: The Mini App sends the browser’s time zone
(
Europe/Moscow) every time you log in, so after moving or flying, the reminder will arrive at 9 p.m. in your new time zone. Until your first login, it usesDEFAULT_TIMEZONE. - If you recorded it yourself, you won’t receive a reminder. Transactions dated for that day are checked: a receipt from yesterday added today won’t count toward today’s total.
- No more than once per local day. If the server was restarted during this time, the reminder will be sent the next minute and won’t be lost.
- If the bot is blocked, there will be no repeats: this is evident from the Telegram response.
- Turn it off for everyone at once without changing the settings:
REMINDERS_ENABLED=false.
Security
The app is private: only two people use it, but the address will sooner or later end up in someone else’s logs. Hence the rule—nothing goes outside except the fact of a failure.
initDataVerified via HMAC-SHA256 with the bot’s token,auth_date— to ensure it’s up to date.- Access is restricted to
ALLOWED_TELEGRAM_IDS; the list is verified both upon login and with every request, so removing a user from the list is enough to make their issued token stop working. - Errors are anonymized: both an invalid signature and a foreign Telegram ID result in the same
“Failed to log in” message. The actual reason is written to the log—
docker compose logs app. During server setup, details are enabled via a flagDEBUG_ERRORS=true. - Login attempts are limited: 10 failed attempts from a single address within 5 minutes—after that, a 429 response.
- The API scheme is closed:
/api/docsand/api/openapi.jsonreturn a 404 untilENABLE_DOCS. Any unknown address at/api/returns the same 404, so brute-forcing won’t reveal which endpoints exist. - The liveness check is silent:
/api/healthit responds{"status":"ok"}and nothing else— no version information or bot mode, which are typically used to identify known vulnerabilities. - Headers: CSP with
frame-ancestorsonly for Telegram (clickjacking protection),nosniff,no-referrer,noindexandrobots.txt— the app won’t show up in search results. The server version is hidden (--no-server-header,-Serverin Caddy). - The webhook secret is verified by comparing a fixed time; a miss returns a 404, not a 403: A 403 would confirm that the webhook address was guessed correctly.
- The service account key and
.envare not committed to Git (see.gitignore).
What this does not replace: a firewall, up-to-date system updates, and backups. The attempt counter resides in the process’s memory and is reset upon restart—this is protection against a flood of attempts, not against a targeted attack.
Tests
make test
150 tests: monetary arithmetic, verification of initData, log invariants (splits, balances,
mutual settlements, soft deletion, outbox), category tree rules, transaction editing
and filters, quick-entry parsing, stability of table string hashes, end-to-end API checks,
data integrity during migration, demo data populating, reminder time zones, transfer of
transactions upon category deletion, account defaults, analysis and correction of mutual settlements,
and a separate file ensuring the app doesn’t reveal more information about itself than necessary.
What’s Next
Upcoming features: comparing months by category on a graph, budgets with notifications, recurring payments, importing CSV statements, photo of a receipt → transaction draft, an MCP server on top of the database for neural network analysis.
Releases
No releases yet.
Open issues
No open issues 🎉








