All projects

FinanceTgApp

★ 0 stars↓ 0 downloads
Open on GitHub ↗

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

  1. In Google Cloud, create a project and enable the Google Sheets API.
  2. Create a service account, download the JSON key, and place it in data/google-credentials.json.
  3. Create a spreadsheet and grant the service account (its email address from the JSON) Editor permissions.
  4. In .env: SHEETS_ENABLED=true and GOOGLE_SPREADSHEET_ID=<id из адреса таблицы>.
  5. Restart the app. The transactions and 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_outbox in 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_hash and are imported back;
  • You can add a row directly in the spreadsheet—just leave id it 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 uses DEFAULT_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.

  • initData Verified 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 flag DEBUG_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/docs and /api/openapi.json return a 404 until ENABLE_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/health it responds {"status":"ok"} and nothing else— no version information or bot mode, which are typically used to identify known vulnerabilities.
  • Headers: CSP with frame-ancestors only for Telegram (clickjacking protection), nosniff, no-referrer, noindex and robots.txt — the app won’t show up in search results. The server version is hidden (--no-server-header, -Server in 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 .env are 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 🎉

This page was machine-translated from Russian

RU