split-bot

Split Bot (“Who owes whom”) — technical spec

Telegram bot + Mini App for splitting shared expenses (trips, parties, flats). Single source of truth for all agents. Do not change contracts here without updating this file.

Runtime architecture

GitHub Pages (static)                 User's PC (python run.py)
https://prostoyus.github.io/          ┌──────────────────────────────────────┐
  split-bot/webapp/  ──fetch()──►     │ cloudflared quick tunnel (https)     │
  (index.html, app.js, style.css)     │   └► uvicorn FastAPI  127.0.0.1:8080 │
                                      │ aiogram 3 bot (long polling)         │
                                      │ SQLite file data/split.db            │
                                      └──────────────────────────────────────┘

Tech

Config (.env, never committed)

BOT_TOKEN=            # from @BotFather
WEBAPP_URL=https://prostoyus.github.io/split-bot/webapp/
PUBLIC_API_URL=       # optional; if empty, cloudflared tunnel is used
API_PORT=8080
DB_PATH=data/split.db
REMIND_EVERY_DAYS=3
ALLOWED_ORIGINS=https://prostoyus.github.io   # comma separated; localhost always allowed

Project layout

split-bot/
  run.py               # entry point (telegram-bot-dev)
  tunnel.py            # cloudflared launcher (telegram-bot-dev)
  app/                 # backend (backend-architect)
    config.py db.py models.py auth.py services.py api.py notifier.py
  bot/                 # telegram bot (telegram-bot-dev)
    main.py handlers.py reminders.py keyboards.py
  webapp/              # Mini App (frontend-developer, mobile-app-builder)
    index.html app.js style.css
  tests/               # pytest
  tools/cloudflared.exe  (gitignored)
  data/                  (gitignored)
  requirements.txt .env.example .gitignore README.md SPEC.md

Data model (SQLite, SQLAlchemy async)

Money is stored as integer minor units (kopecks). Never floats.

Balance rules

Auth

REST API (JSON, prefix /api)

User object everywhere: { "id": int, "name": str, "username": str|null } (name = first + last name).

Method Path Body Response
GET /api/health – { "ok": true } (no auth)
GET /api/config – { "bot_username": str }
GET /api/me – user
GET /api/events – [{ id, title, currency, members_count, my_net }] newest first
POST /api/events { title, currency? } event detail (creator becomes member)
GET /api/events/{id} – { id, title, currency, invite_code, invite_link, owner_id, members:[user], my_net } — invite_link = https://t.me/<bot_username>?start=join_<invite_code>
POST /api/join { invite_code } event detail (idempotent)
GET /api/events/{id}/expenses – [{ id, payer:user, amount, description, split, created_at, created_by, shares:[{user, amount}] }] newest first
POST /api/events/{id}/expenses { amount, description, payer_id, split?: "equal"\|"exact", participant_ids?:[int], shares?:[{user_id, amount}] } expense. See “Split modes” below
DELETE /api/expenses/{id} – { "ok": true } — only creator or payer
GET /api/events/{id}/balances – { balances:[{ user, net }], transfers:[{ from:user, to:user, amount }] }
GET /api/events/{id}/settlements – [{ id, from:user, to:user, amount, status, created_at }] newest first
POST /api/events/{id}/settlements { to_id, amount } settlement (status pending); from = current user; to must be member, != self. amount ≤ sender’s debt to to = amount of the current minimal transfer from→to minus sender’s pending settlements to to (no such transfer → 400 “Сумма больше вашего долга этому участнику”); only one pending per pair (400 “Предыдущий возврат ещё ждёт подтверждения”)
POST /api/settlements/{id}/confirm – settlement — only to user, only if pending (atomic conditional UPDATE; otherwise 400 “Этот возврат уже обработан”)
POST /api/settlements/{id}/reject – same rules as confirm

Errors: { "detail": "<Russian human-readable message>" } with proper status codes (400/401/403/404/422/429). Unexpected errors → 500 { "detail": "Внутренняя ошибка сервера" }, produced inside the CORS layer so CORS headers are present. Amounts in API are integers in minor units.

Split modes (POST expenses)

Notifications (app/notifier.py)

Backend never imports aiogram. It calls an injected async callable:

# app/notifier.py
Notify = Callable[[int, str, dict | None], Awaitable[None]]  # (user_id, html_text, extra) ; extra may hold {"event_id": int}
set_notifier(fn); async def notify(user_id, text, extra=None)  # no-op if not set; swallow & log errors
async def drain(timeout)  # await pending deliveries; cancel leftovers after timeout

notify() does not wait for delivery: it schedules a background task (strong refs kept until done), so HTTP responses are never blocked by Telegram. Call drain(timeout) on shutdown (and in tests before asserting).

The bot wires it in run.py so notifications are sent with an “Открыть” web_app button to the event. Events that trigger notifications (to everyone except the actor):

Bot (aiogram 3)

Mini App screens (Russian UI, Telegram theme colors via CSS vars --tg-theme-*)

  1. Мои события — list with my balance per event (green “вам должны”, red “вы должны”), button “Создать событие”.
  2. Создать событие — title, currency.
  3. Событие (tabs): Траты (list + add), Балансы (who owes whom + “Я вернул” button on my transfers), Участники (list + “Пригласить” button → Telegram.WebApp.openTelegramLink("https://t.me/share/url?url=<invite_link>&text=...") and copy link). Pending settlements addressed to me show “Подтвердить / Отклонить”.
  4. Добавить трату — amount (decimal input in major units, converted to kopecks), description, who paid (select), split between (checkboxes, all by default).
    • Use Telegram MainButton / BackButton where natural, haptic feedback, Telegram.WebApp.ready() and expand().
    • Opened with ?event=<id> → go directly to that event.
    • Money formatting: 1 500,50 ₽ style (ru-RU), currency symbol by code.
    • Must work in light and dark Telegram themes, 320px+ width.
    • For local testing outside Telegram: if Telegram.WebApp.initData is empty, show a clear message “Откройте через Telegram-бота”.