Build Telegram savings tracker WebApp

This commit is contained in:
Codex 2026-09-03 18:18:08 +00:00
commit 29bd6b3fd8
19 changed files with 6155 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.git
.env
.pytest_cache
__pycache__
*.py[cod]
data

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
TELEGRAM_TOKEN=123456789:replace_me
BASE_APP_URL=https://example.com

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.env
.venv/
.pytest_cache/
__pycache__/
*.py[cod]
*.sqlite3
data/
.DS_Store

21
Caddyfile Normal file
View file

@ -0,0 +1,21 @@
{$BASE_APP_URL:http://localhost} {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
handle_path /app* {
reverse_proxy app:8000
}
handle /api/* {
reverse_proxy app:8000
}
handle {
redir /app/ 308
}
}

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM ghcr.io/astral-sh/uv:0.12.9 AS uv
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
DATA_DIR=/data
COPY --from=uv /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app ./app
COPY static ./static
RUN mkdir -p /data/avatars && chown -R 10001:10001 /app /data
USER 10001:10001
EXPOSE 8000
CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

62
README.md Normal file
View file

@ -0,0 +1,62 @@
# Пожитки
Telegram WebApp для учёта поступлений и расходов. Бот принимает записи через
`Telegram.WebApp.sendData`, сохраняет подтверждённый Telegram ID отправителя,
имя, username и аватар. WebApp показывает баланс, категории, общий рейтинг и
рейтинг среди друзей.
## Запуск
1. Создайте бота через BotFather и получите токен.
2. Скопируйте `.env.example` в `.env` и укажите:
```dotenv
TELEGRAM_TOKEN=123456789:your_token
BASE_APP_URL=https://example.com
```
3. Направьте DNS домена на сервер и откройте порты 80/443.
4. Запустите:
```bash
docker compose up -d --build
```
Caddy автоматически получает TLS-сертификат. WebApp доступен по
`https://example.com/app/`, read-only API — по `https://example.com/api/`.
Команда `/start` выводит reply-кнопку запуска WebApp. Это важно: Telegram
поддерживает `WebApp.sendData()` для приложения, открытого через
`KeyboardButton`, и присылает payload боту в `web_app_data`.
## API
- `GET /api/healthz` — проверка состояния;
- `GET /api/leaderboard?limit=100` — общий рейтинг;
- `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей;
- `GET /api/users/{id}/summary` — публичная сводка пользователя;
- `GET /api/users/{id}/friends` — Telegram ID друзей;
- `GET /api/avatars/{id}` — сохранённый аватар;
- `GET /api/docs` — OpenAPI UI.
Процент накоплений: `(поступления расходы) / поступления × 100`. При нулевых
поступлениях он равен 0%. Суммы хранятся целым числом копеек, SQLite работает в
WAL-режиме, данные лежат в Docker volume `app-data`.
API намеренно не принимает операции записи. Пользовательский ID для операции
берётся из Telegram-сообщения, поэтому его нельзя подменить payload-ом WebApp.
При этом лидерборды и сводки публичны — не публикуйте там сведения, которые не
хотите показывать другим участникам.
## Разработка
Зависимости управляются `uv`:
```bash
uv sync
uv run pytest
TELEGRAM_TOKEN='' DATA_DIR=./data uv run uvicorn app.main:app --reload
```
Для браузерного предпросмотра можно открыть `/app/?user_id=123`. Отправка
операции вне Telegram специально отключена.

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Pozhitki Telegram WebApp backend."""

189
app/bot.py Normal file
View file

@ -0,0 +1,189 @@
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from threading import Thread
from typing import Any
import telebot
from telebot import types
from app.config import Settings
from app.db import Database, TransactionInput, amount_to_cents
logger = logging.getLogger(__name__)
CATEGORY_PATTERN = re.compile(r"^[^\x00-\x1f]{1,40}$")
def parse_transaction(raw: str) -> TransactionInput:
try:
payload: dict[str, Any] = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError("Не удалось прочитать данные") from exc
if payload.get("type") != "transaction" or payload.get("v") != 1:
raise ValueError("Неизвестный формат данных")
kind = payload.get("kind")
if kind not in {"income", "expense"}:
raise ValueError("Некорректный тип операции")
category = str(payload.get("category", "")).strip()
if not CATEGORY_PATTERN.fullmatch(category):
raise ValueError("Категория должна содержать от 1 до 40 символов")
note = str(payload.get("note", "")).strip() or None
if note and len(note) > 160:
raise ValueError("Комментарий слишком длинный")
occurred_at_raw = str(payload.get("occurred_at", "")).strip()
try:
occurred_at = datetime.fromisoformat(occurred_at_raw.replace("Z", "+00:00"))
except ValueError:
occurred_at = datetime.now(UTC)
if occurred_at.tzinfo is None:
occurred_at = occurred_at.replace(tzinfo=UTC)
return TransactionInput(
kind=kind,
amount_cents=amount_to_cents(payload.get("amount")),
category=category,
note=note,
occurred_at=occurred_at.astimezone(UTC).isoformat(),
)
class TelegramBotService:
def __init__(self, settings: Settings, database: Database):
self.settings = settings
self.database = database
self.bot = telebot.TeleBot(settings.telegram_token, threaded=True)
self.thread: Thread | None = None
self._register_handlers()
def _keyboard(self) -> types.ReplyKeyboardMarkup:
keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True)
keyboard.add(
types.KeyboardButton(
text="Открыть Пожитки",
web_app=types.WebAppInfo(url=self.settings.webapp_url),
)
)
return keyboard
def _register_handlers(self) -> None:
@self.bot.message_handler(commands=["start"])
def start(message: types.Message) -> None:
user = message.from_user
if user is None:
return
self._save_user(user)
parts = (message.text or "").split(maxsplit=1)
friend_added = False
if len(parts) == 2 and parts[1].startswith("friend_"):
try:
inviter_id = int(parts[1].removeprefix("friend_"))
except ValueError:
inviter_id = 0
if inviter_id > 0:
friend_added = self.database.add_friendship(user.id, inviter_id)
prefix = "Теперь вы друзья!\n\n" if friend_added else ""
self.bot.send_message(
message.chat.id,
prefix
+ "Пожитки считают доходы, расходы и процент накоплений. "
"Нажмите кнопку ниже, чтобы открыть приложение.",
reply_markup=self._keyboard(),
)
@self.bot.message_handler(content_types=["web_app_data"])
def web_app_data(message: types.Message) -> None:
user = message.from_user
if user is None or message.web_app_data is None:
return
self._save_user(user)
try:
item = parse_transaction(message.web_app_data.data)
self.database.add_transaction(user.id, item)
except ValueError as exc:
self.bot.send_message(
message.chat.id,
f"Не получилось сохранить операцию: {exc}",
reply_markup=self._keyboard(),
)
return
operation = "Доход" if item.kind == "income" else "Расход"
self.bot.send_message(
message.chat.id,
f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.",
reply_markup=self._keyboard(),
)
@self.bot.message_handler(commands=["help"])
def help_message(message: types.Message) -> None:
self.bot.send_message(
message.chat.id,
"Добавляйте операции через WebApp. Ссылка «Стать друзьями» находится на вкладке рейтинга.",
reply_markup=self._keyboard(),
)
def _save_user(self, user: types.User) -> None:
self.database.upsert_user(
user_id=user.id,
first_name=user.first_name or "Пользователь",
last_name=user.last_name,
username=user.username,
)
try:
photos = self.bot.get_user_profile_photos(user.id, limit=1)
if not photos.photos:
return
photo = photos.photos[0][-1]
if self.database.get_avatar_file_id(user.id) == photo.file_id:
return
file_info = self.bot.get_file(photo.file_id)
content = self.bot.download_file(file_info.file_path)
suffix = Path(file_info.file_path).suffix or ".jpg"
filename = f"{user.id}{suffix}"
target = self.settings.avatars_dir / filename
self.settings.avatars_dir.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
self.database.upsert_user(
user_id=user.id,
first_name=user.first_name or "Пользователь",
last_name=user.last_name,
username=user.username,
avatar_file_id=photo.file_id,
avatar_path=filename,
)
except Exception:
logger.exception("Could not update avatar for Telegram user %s", user.id)
def start(self) -> None:
me = self.bot.get_me()
if me.username:
self.database.set_meta("bot_username", me.username)
self.bot.set_my_commands(
[
types.BotCommand("start", "Открыть приложение"),
types.BotCommand("help", "Как пользоваться"),
]
)
self.thread = Thread(
target=self.bot.infinity_polling,
kwargs={"skip_pending": True, "timeout": 30, "long_polling_timeout": 30},
name="telegram-polling",
daemon=True,
)
self.thread.start()
logger.info("Telegram bot @%s started", me.username)
def stop(self) -> None:
self.bot.stop_polling()

32
app/config.py Normal file
View file

@ -0,0 +1,32 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class Settings:
telegram_token: str
base_app_url: str
data_dir: Path
@property
def database_path(self) -> Path:
return self.data_dir / "pozhitki.sqlite3"
@property
def avatars_dir(self) -> Path:
return self.data_dir / "avatars"
@property
def webapp_url(self) -> str:
return f"{self.base_app_url.rstrip('/')}/app/"
def load_settings() -> Settings:
return Settings(
telegram_token=os.getenv("TELEGRAM_TOKEN", "").strip(),
base_app_url=os.getenv("BASE_APP_URL", "http://localhost").strip(),
data_dir=Path(os.getenv("DATA_DIR", "/data")),
)

323
app/db.py Normal file
View file

@ -0,0 +1,323 @@
from __future__ import annotations
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any, Iterable
SCHEMA = """
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT,
nickname TEXT NOT NULL,
username TEXT,
avatar_file_id TEXT,
avatar_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense')),
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
category TEXT NOT NULL,
note TEXT,
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_user_id
ON transactions(user_id);
CREATE TABLE IF NOT EXISTS friendships (
user_id_low INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
user_id_high INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id_low, user_id_high),
CHECK (user_id_low < user_id_high)
);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
@dataclass(frozen=True, slots=True)
class TransactionInput:
kind: str
amount_cents: int
category: str
note: str | None
occurred_at: str
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def amount_to_cents(value: Any) -> int:
try:
amount = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError("Некорректная сумма") from exc
cents = int(amount * 100)
if cents <= 0:
raise ValueError("Сумма должна быть больше нуля")
if cents > 100_000_000_000_00:
raise ValueError("Сумма слишком большая")
return cents
class Database:
def __init__(self, path: Path):
self.path = path
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=15)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 15000")
return connection
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self.connect()) as connection:
connection.executescript(SCHEMA)
connection.commit()
def upsert_user(
self,
*,
user_id: int,
first_name: str,
last_name: str | None,
username: str | None,
avatar_file_id: str | None = None,
avatar_path: str | None = None,
) -> None:
now = utc_now()
nickname = " ".join(part for part in (first_name, last_name) if part).strip()
nickname = nickname or f"Пользователь {user_id}"
with closing(self.connect()) as connection:
connection.execute(
"""
INSERT INTO users (
user_id, first_name, last_name, nickname, username,
avatar_file_id, avatar_path, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
first_name = excluded.first_name,
last_name = excluded.last_name,
nickname = excluded.nickname,
username = excluded.username,
avatar_file_id = COALESCE(excluded.avatar_file_id, users.avatar_file_id),
avatar_path = COALESCE(excluded.avatar_path, users.avatar_path),
updated_at = excluded.updated_at
""",
(
user_id,
first_name,
last_name,
nickname,
username,
avatar_file_id,
avatar_path,
now,
now,
),
)
connection.commit()
def get_avatar_file_id(self, user_id: int) -> str | None:
with closing(self.connect()) as connection:
row = connection.execute(
"SELECT avatar_file_id FROM users WHERE user_id = ?", (user_id,)
).fetchone()
return row["avatar_file_id"] if row else None
def add_transaction(self, user_id: int, item: TransactionInput) -> int:
with closing(self.connect()) as connection:
cursor = connection.execute(
"""
INSERT INTO transactions (
user_id, kind, amount_cents, category, note,
occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
item.kind,
item.amount_cents,
item.category,
item.note,
item.occurred_at,
utc_now(),
),
)
connection.commit()
return int(cursor.lastrowid)
def add_friendship(self, first_user_id: int, second_user_id: int) -> bool:
if first_user_id == second_user_id:
return False
low, high = sorted((first_user_id, second_user_id))
with closing(self.connect()) as connection:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO friendships (user_id_low, user_id_high, created_at)
SELECT ?, ?, ?
WHERE EXISTS (SELECT 1 FROM users WHERE user_id = ?)
AND EXISTS (SELECT 1 FROM users WHERE user_id = ?)
""",
(low, high, utc_now(), low, high),
)
connection.commit()
return cursor.rowcount > 0
def friend_ids(self, user_id: int) -> list[int]:
with closing(self.connect()) as connection:
rows = connection.execute(
"""
SELECT CASE
WHEN user_id_low = ? THEN user_id_high
ELSE user_id_low
END AS friend_id
FROM friendships
WHERE user_id_low = ? OR user_id_high = ?
ORDER BY created_at DESC
""",
(user_id, user_id, user_id),
).fetchall()
return [int(row["friend_id"]) for row in rows]
def set_meta(self, key: str, value: str) -> None:
with closing(self.connect()) as connection:
connection.execute(
"""
INSERT INTO app_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
connection.commit()
def get_meta(self, key: str) -> str | None:
with closing(self.connect()) as connection:
row = connection.execute(
"SELECT value FROM app_meta WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def summary(self, user_id: int) -> dict[str, Any] | None:
with closing(self.connect()) as connection:
row = connection.execute(
"""
SELECT
u.user_id,
u.nickname,
u.username,
u.avatar_path,
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
FROM users u
LEFT JOIN transactions t ON t.user_id = u.user_id
WHERE u.user_id = ?
GROUP BY u.user_id
""",
(user_id,),
).fetchone()
if not row:
return None
categories = connection.execute(
"""
SELECT kind, category, SUM(amount_cents) AS amount_cents
FROM transactions
WHERE user_id = ?
GROUP BY kind, category
ORDER BY amount_cents DESC
""",
(user_id,),
).fetchall()
return self._serialize_totals(dict(row), categories)
def leaderboard(
self, *, user_ids: Iterable[int] | None = None, limit: int = 100
) -> list[dict[str, Any]]:
ids = list(dict.fromkeys(user_ids or []))
where = ""
params: list[Any] = []
if ids:
placeholders = ",".join("?" for _ in ids)
where = f"WHERE u.user_id IN ({placeholders})"
params.extend(ids)
params.append(limit)
with closing(self.connect()) as connection:
rows = connection.execute(
f"""
SELECT
u.user_id,
u.nickname,
u.username,
u.avatar_path,
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
FROM users u
LEFT JOIN transactions t ON t.user_id = u.user_id
{where}
GROUP BY u.user_id
ORDER BY
CASE
WHEN SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) > 0
THEN 1.0 * (
SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) -
SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END)
) / SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END)
ELSE 0
END DESC,
(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) -
SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END)) DESC,
u.user_id ASC
LIMIT ?
""",
params,
).fetchall()
result = []
for rank, row in enumerate(rows, start=1):
item = self._serialize_totals(dict(row), [])
item["rank"] = rank
result.append(item)
return result
@staticmethod
def _serialize_totals(
row: dict[str, Any], categories: Iterable[sqlite3.Row]
) -> dict[str, Any]:
income = int(row["income_cents"])
expense = int(row["expense_cents"])
balance = income - expense
percent = round(balance * 100 / income, 1) if income else 0.0
return {
"user_id": int(row["user_id"]),
"nickname": row["nickname"],
"username": row["username"],
"avatar_url": (
f"/api/avatars/{row['user_id']}" if row.get("avatar_path") else None
),
"income_cents": income,
"expense_cents": expense,
"balance_cents": balance,
"saved_percent": percent,
"categories": [dict(category) for category in categories],
}

128
app/main.py Normal file
View file

@ -0,0 +1,128 @@
from __future__ import annotations
import logging
import mimetypes
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.bot import TelegramBotService
from app.config import load_settings
from app.db import Database
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
settings = load_settings()
database = Database(settings.database_path)
bot_service: TelegramBotService | None = None
@asynccontextmanager
async def lifespan(_: FastAPI):
global bot_service
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.avatars_dir.mkdir(parents=True, exist_ok=True)
database.initialize()
if settings.telegram_token:
try:
bot_service = TelegramBotService(settings, database)
bot_service.start()
except Exception:
logger.exception("Telegram bot could not start; HTTP application remains available")
else:
logger.warning("TELEGRAM_TOKEN is empty; running HTTP application without the bot")
yield
if bot_service:
bot_service.stop()
app = FastAPI(
title="Пожитки API",
version="0.1.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
@app.get("/api/healthz")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/config")
def public_config() -> dict[str, str | None]:
return {"bot_username": database.get_meta("bot_username")}
@app.get("/api/users/{user_id}/summary")
def user_summary(user_id: int) -> dict:
result = database.summary(user_id)
if result is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return result
@app.get("/api/users/{user_id}/friends")
def user_friends(user_id: int) -> dict[str, list[int]]:
return {"user_ids": database.friend_ids(user_id)}
def parse_user_ids(raw: str | None) -> list[int] | None:
if not raw:
return None
values: list[int] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
value = int(item)
except ValueError as exc:
raise HTTPException(status_code=422, detail="user_ids должен быть списком чисел") from exc
if value <= 0:
raise HTTPException(status_code=422, detail="user_ids должен содержать положительные ID")
values.append(value)
if len(values) > 100:
raise HTTPException(status_code=422, detail="Можно запросить не более 100 пользователей")
return values or None
@app.get("/api/leaderboard")
def leaderboard(
user_ids: str | None = Query(
default=None, description="Telegram user ID через запятую"
),
limit: int = Query(default=100, ge=1, le=100),
) -> dict[str, list[dict]]:
return {"items": database.leaderboard(user_ids=parse_user_ids(user_ids), limit=limit)}
@app.get("/api/avatars/{user_id}", response_class=FileResponse)
def avatar(user_id: int) -> FileResponse:
summary = database.summary(user_id)
if not summary or not summary["avatar_url"]:
raise HTTPException(status_code=404, detail="Аватар не найден")
with database.connect() as connection:
row = connection.execute(
"SELECT avatar_path FROM users WHERE user_id = ?", (user_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Аватар не найден")
filename = Path(row["avatar_path"]).name
path = settings.avatars_dir / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="Аватар не найден")
media_type = mimetypes.guess_type(path)[0] or "image/jpeg"
return FileResponse(path, media_type=media_type)
static_dir = Path(__file__).resolve().parent.parent / "static"
app.mount("/", StaticFiles(directory=static_dir, html=True), name="webapp")

33
docker-compose.yml Normal file
View file

@ -0,0 +1,33 @@
services:
app:
build: .
restart: unless-stopped
environment:
TELEGRAM_TOKEN: ${TELEGRAM_TOKEN:?Set TELEGRAM_TOKEN in .env}
BASE_APP_URL: ${BASE_APP_URL:?Set BASE_APP_URL in .env}
DATA_DIR: /data
volumes:
- app-data:/data
expose:
- "8000"
caddy:
image: caddy:2.10-alpine
restart: unless-stopped
depends_on:
- app
environment:
BASE_APP_URL: ${BASE_APP_URL:?Set BASE_APP_URL in .env}
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
app-data:
caddy-data:
caddy-config:

21
pyproject.toml Normal file
View file

@ -0,0 +1,21 @@
[project]
name = "pozhitki"
version = "0.1.0"
description = "Telegram WebApp for tracking income, expenses and savings"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0,<1",
"pytelegrambotapi>=4.23.0,<5",
"uvicorn[standard]>=0.30.0,<1",
]
[dependency-groups]
dev = [
"httpx>=0.27.0,<1",
"pytest>=8.3.0,<9",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

236
static/app.js Normal file
View file

@ -0,0 +1,236 @@
(() => {
"use strict";
const tg = window.Telegram?.WebApp;
const tgUser = tg?.initDataUnsafe?.user;
const query = new URLSearchParams(window.location.search);
const previewUserId = Number(query.get("user_id")) || 0;
const user = tgUser || (previewUserId ? { id: previewUserId, first_name: "Предпросмотр" } : null);
const userId = Number(user?.id) || 0;
const state = { kind: "income", board: "all", summary: null, config: null };
const categories = {
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
};
const $ = (selector) => document.querySelector(selector);
const money = new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 2 });
function initTelegram() {
if (!tg) return;
tg.ready();
tg.expand();
tg.enableClosingConfirmation?.();
if (tg.isVersionAtLeast?.("6.1")) tg.setHeaderColor("secondary_bg_color");
if (tg.isVersionAtLeast?.("7.10")) tg.setBottomBarColor("secondary_bg_color");
}
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
window.setTimeout(() => toast.classList.remove("show"), 2600);
}
function setIdentity() {
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ") || "Гость";
$("#userName").textContent = name;
$("#userInitial").textContent = name.slice(0, 1).toUpperCase();
}
function bindNavigation() {
document.querySelectorAll(".bottom-nav button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".bottom-nav button, .view").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
$(`#${button.dataset.view}`).classList.add("active");
if (button.dataset.view === "leaderboardView") loadLeaderboard();
});
});
}
function fillCategories() {
const select = $("#category");
select.innerHTML = categories[state.kind].map((category) => `<option value="${category}">${category}</option>`).join("");
$("#customCategoryWrap").classList.add("hidden");
$("#customCategory").required = false;
}
function bindForm() {
document.querySelectorAll(".segment").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".segment").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
state.kind = button.dataset.kind;
fillCategories();
});
});
$("#category").addEventListener("change", (event) => {
const custom = event.target.value === "Другое";
$("#customCategoryWrap").classList.toggle("hidden", !custom);
$("#customCategory").required = custom;
if (custom) $("#customCategory").focus();
});
$("#transactionForm").addEventListener("submit", submitTransaction);
}
function submitTransaction(event) {
event.preventDefault();
const amount = Number($("#amount").value);
const selected = $("#category").value;
const category = selected === "Другое" ? $("#customCategory").value.trim() : selected;
if (!Number.isFinite(amount) || amount <= 0 || !category) {
showToast("Проверьте сумму и категорию");
return;
}
const payload = {
v: 1,
type: "transaction",
kind: state.kind,
amount: amount.toFixed(2),
category: category.slice(0, 40),
note: $("#note").value.trim().slice(0, 160),
occurred_at: new Date().toISOString(),
};
const encoded = JSON.stringify(payload);
if (encoded.length > 4096) {
showToast("Запись получилась слишком длинной");
return;
}
if (!tg?.sendData || !tg.initData) {
showToast("Сохранение доступно при открытии из Telegram");
return;
}
tg.HapticFeedback?.notificationOccurred("success");
tg.sendData(encoded);
}
async function fetchJson(url) {
const response = await fetch(url, { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function loadSummary() {
if (!userId) return;
try {
state.summary = await fetchJson(`../api/users/${userId}/summary`);
renderSummary(state.summary);
} catch (error) {
console.warn("Summary is not available", error);
}
}
function renderSummary(summary) {
$("#balanceValue").textContent = money.format(summary.balance_cents / 100);
$("#incomeValue").textContent = money.format(summary.income_cents / 100);
$("#expenseValue").textContent = money.format(summary.expense_cents / 100);
const percent = Number(summary.saved_percent);
$("#savedPercent").textContent = `${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
$("#savingProgress").style.width = `${Math.max(0, Math.min(100, percent))}%`;
renderCategories(summary.categories || []);
}
function renderCategories(items) {
const list = $("#categoryList");
if (!items.length) return;
const max = Math.max(...items.map((item) => item.amount_cents));
$("#categoryCaption").textContent = `${items.length} категорий`;
list.classList.remove("empty-state");
list.innerHTML = items.slice(0, 8).map((item) => `
<div class="category-item ${item.kind === "expense" ? "expense-item" : ""}">
<strong>${escapeHtml(item.category)}</strong>
<span>${item.kind === "income" ? "+" : ""}${money.format(item.amount_cents / 100)}</span>
<div class="category-bar"><i style="width:${Math.max(4, item.amount_cents * 100 / max)}%"></i></div>
</div>`).join("");
}
function escapeHtml(value) {
const element = document.createElement("span");
element.textContent = value;
return element.innerHTML;
}
function bindLeaderboard() {
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
$("#shareButton").addEventListener("click", shareInvite);
}
function switchBoard(board) {
state.board = board;
$("#allBoardButton").classList.toggle("active", board === "all");
$("#friendsBoardButton").classList.toggle("active", board === "friends");
loadLeaderboard();
}
async function loadLeaderboard() {
const list = $("#leaderboardList");
list.className = "leaderboard-list loading";
list.textContent = "Загружаем рейтинг…";
$("#friendHint").classList.add("hidden");
try {
let url = "../api/leaderboard?limit=100";
if (state.board === "friends") {
if (!userId) throw new Error("No Telegram user");
const friends = await fetchJson(`../api/users/${userId}/friends`);
const ids = [userId, ...friends.user_ids];
url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`;
$("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0);
}
const data = await fetchJson(url);
renderLeaderboard(data.items || []);
} catch (error) {
list.textContent = "Не удалось загрузить рейтинг";
console.warn("Leaderboard is not available", error);
}
}
function renderLeaderboard(items) {
const list = $("#leaderboardList");
list.className = "leaderboard-list";
if (!items.length) {
list.classList.add("loading");
list.textContent = "В рейтинге пока никого нет";
return;
}
list.innerHTML = items.map((item, index) => {
const initial = escapeHtml((item.nickname || "?").slice(0, 1).toUpperCase());
const avatar = item.avatar_url
? `<img class="leader-avatar" src="..${item.avatar_url}" alt="" loading="lazy" />`
: `<span class="leader-avatar avatar-fallback">${initial}</span>`;
const percent = Number(item.saved_percent);
return `<div class="leader-row ${item.user_id === userId ? "me" : ""}">
<span class="rank">${index + 1}</span>
${avatar}
<span class="leader-name"><strong>${escapeHtml(item.nickname)}</strong><small>${item.user_id === userId ? "Это вы" : money.format(item.balance_cents / 100)}</small></span>
<span class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</span>
</div>`;
}).join("");
}
async function shareInvite() {
if (!userId) {
showToast("Откройте приложение из Telegram");
return;
}
try {
state.config ||= await fetchJson("../api/config");
if (!state.config.bot_username) throw new Error("Bot username is missing");
const invite = `https://t.me/${state.config.bot_username}?start=friend_${userId}`;
const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(invite)}&text=${encodeURIComponent("Давай копить вместе в Пожитках")}`;
if (tg?.openTelegramLink) tg.openTelegramLink(shareUrl);
else window.open(shareUrl, "_blank", "noopener,noreferrer");
} catch (error) {
showToast("Ссылка появится после запуска бота");
}
}
initTelegram();
setIdentity();
bindNavigation();
bindForm();
bindLeaderboard();
fillCategories();
loadSummary();
})();

114
static/index.html Normal file
View file

@ -0,0 +1,114 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f1f0ea" />
<title>Пожитки</title>
<link rel="stylesheet" href="./styles.css" />
<script src="./telegram-web-app.js"></script>
<script src="./app.js" defer></script>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Личный капитал</p>
<h1>Пожитки</h1>
</div>
<div class="user-chip" aria-label="Текущий пользователь">
<span id="userInitial" class="mini-avatar">Я</span>
<span id="userName">Гость</span>
</div>
</header>
<section id="dashboardView" class="view active" aria-labelledby="dashboardTab">
<article class="hero-card">
<p>Накоплено</p>
<strong id="balanceValue">0 ₽</strong>
<div class="saving-row">
<span id="savedPercent">0%</span>
<div class="progress"><i id="savingProgress"></i></div>
</div>
<small>от всех поступлений</small>
</article>
<div class="metric-grid">
<article class="metric income">
<span class="metric-icon"></span>
<div><small>Поступления</small><strong id="incomeValue">0 ₽</strong></div>
</article>
<article class="metric expense">
<span class="metric-icon"></span>
<div><small>Расходы</small><strong id="expenseValue">0 ₽</strong></div>
</article>
</div>
<div class="section-title">
<h2>Структура денег</h2>
<span id="categoryCaption">Пока пусто</span>
</div>
<div id="categoryList" class="category-list empty-state">
Добавьте первую операцию — категории появятся здесь.
</div>
</section>
<section id="addView" class="view" aria-labelledby="addTab">
<div class="section-title add-heading">
<div><p class="eyebrow">Новая запись</p><h2>Движение денег</h2></div>
</div>
<form id="transactionForm" class="transaction-form">
<div class="segmented" role="group" aria-label="Тип операции">
<button type="button" class="segment active" data-kind="income">Поступление</button>
<button type="button" class="segment" data-kind="expense">Расход</button>
</div>
<label class="amount-field">
<span>Сумма</span>
<div><input id="amount" name="amount" type="number" inputmode="decimal" min="0.01" step="0.01" placeholder="0" required /><b></b></div>
</label>
<label>
<span>Категория</span>
<select id="category" name="category" required></select>
</label>
<label id="customCategoryWrap" class="hidden">
<span>Своя категория</span>
<input id="customCategory" maxlength="40" placeholder="Например, Фриланс" />
</label>
<label>
<span>Комментарий <em>необязательно</em></span>
<input id="note" name="note" maxlength="160" placeholder="За что или откуда" />
</label>
<button class="primary-button" type="submit">Сохранить через Telegram</button>
<p class="form-hint">Telegram передаст запись боту вместе с вашим подтверждённым ID.</p>
</form>
</section>
<section id="leaderboardView" class="view" aria-labelledby="leaderboardTab">
<div class="section-title board-heading">
<div><p class="eyebrow">Сберегательный рейтинг</p><h2>Лидерборд</h2></div>
<button id="shareButton" class="icon-button" type="button" aria-label="Пригласить друга"></button>
</div>
<div class="board-switch" role="tablist">
<button id="allBoardButton" class="active" type="button">Все</button>
<button id="friendsBoardButton" type="button">Друзья</button>
</div>
<div id="leaderboardList" class="leaderboard-list loading">Загружаем рейтинг…</div>
<p id="friendHint" class="friend-hint hidden">Поделитесь ссылкой: когда друг запустит бота, он появится здесь.</p>
</section>
</main>
<nav class="bottom-nav" aria-label="Основная навигация">
<button id="dashboardTab" class="active" data-view="dashboardView"><span></span>Обзор</button>
<button id="addTab" data-view="addView"><span class="add-mark"></span>Добавить</button>
<button id="leaderboardTab" data-view="leaderboardView"><span></span>Рейтинг</button>
</nav>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>

131
static/styles.css Normal file
View file

@ -0,0 +1,131 @@
:root {
--bg: var(--tg-theme-bg-color, #f1f0ea);
--surface: var(--tg-theme-secondary-bg-color, #ffffff);
--text: var(--tg-theme-text-color, #17201c);
--muted: var(--tg-theme-hint-color, #707a74);
--accent: var(--tg-theme-button-color, #216a4b);
--accent-text: var(--tg-theme-button-text-color, #ffffff);
--link: var(--tg-theme-link-color, #216a4b);
--danger: var(--tg-theme-destructive-text-color, #bd4a3e);
--line: color-mix(in srgb, var(--text) 12%, transparent);
--shadow: 0 16px 50px color-mix(in srgb, var(--text) 9%, transparent);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); }
body {
min-height: 100vh;
min-height: 100dvh;
padding: max(14px, env(safe-area-inset-top)) 18px calc(96px + env(safe-area-inset-bottom));
background-image:
radial-gradient(circle at 90% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 32%),
linear-gradient(180deg, color-mix(in srgb, var(--surface) 35%, transparent), transparent 28%);
}
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
.shell { width: min(100%, 560px); margin: 0 auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 2px 26px; }
.eyebrow { margin: 0 0 3px; text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; color: var(--muted); }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: 30px; line-height: 1; letter-spacing: -.04em; }
h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.user-chip { display: flex; align-items: center; gap: 8px; max-width: 48%; padding: 5px 11px 5px 5px; border: 1px solid var(--line); border-radius: 999px; background: color-mix(in srgb, var(--surface) 72%, transparent); font-size: 12px; font-weight: 650; white-space: nowrap; overflow: hidden; }
.mini-avatar { display: grid; flex: 0 0 auto; place-items: center; width: 29px; height: 29px; border-radius: 50%; background: var(--accent); color: var(--accent-text); }
.view { display: none; animation: reveal .25s ease; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
.hero-card {
min-height: 210px;
padding: 27px;
border-radius: 29px;
color: #f5f7f3;
background:
linear-gradient(135deg, rgba(255,255,255,.11), transparent 45%),
linear-gradient(145deg, color-mix(in srgb, var(--accent) 82%, #132e24), color-mix(in srgb, var(--accent) 50%, #0c1914));
box-shadow: var(--shadow);
}
.hero-card p { margin-bottom: 15px; opacity: .72; font-size: 13px; }
.hero-card strong { display: block; margin-bottom: 32px; font-size: clamp(38px, 10vw, 54px); letter-spacing: -.055em; line-height: 1; overflow-wrap: anywhere; }
.saving-row { display: flex; align-items: center; gap: 12px; }
.saving-row span { min-width: 48px; font-weight: 800; }
.progress { flex: 1; height: 7px; overflow: hidden; border-radius: 99px; background: rgba(255,255,255,.2); }
.progress i { display: block; width: 0; height: 100%; border-radius: inherit; background: #e8d487; transition: width .4s ease; }
.hero-card small { display: block; margin-top: 7px; opacity: .55; font-size: 11px; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; margin: 12px 0 28px; }
.metric { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 17px 15px; border: 1px solid var(--line); border-radius: 20px; background: var(--surface); }
.metric-icon { display: grid; place-items: center; flex: 0 0 auto; width: 35px; height: 35px; border-radius: 12px; font-size: 19px; }
.income .metric-icon { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
.expense .metric-icon { color: var(--danger); background: color-mix(in srgb, var(--danger) 13%, transparent); }
.metric div { min-width: 0; }
.metric small { display: block; margin-bottom: 4px; color: var(--muted); font-size: 10px; }
.metric strong { display: block; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.section-title { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 0 3px 14px; }
.section-title > span { color: var(--muted); font-size: 11px; }
.category-list { overflow: hidden; border: 1px solid var(--line); border-radius: 21px; background: var(--surface); }
.empty-state { padding: 25px; color: var(--muted); text-align: center; font-size: 13px; line-height: 1.5; }
.category-item { display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; padding: 15px 17px; border-bottom: 1px solid var(--line); }
.category-item:last-child { border-bottom: 0; }
.category-item strong { font-size: 13px; }
.category-item span { color: var(--muted); font-size: 12px; }
.category-item .category-bar { grid-column: 1 / -1; height: 3px; overflow: hidden; border-radius: 4px; background: var(--line); }
.category-bar i { display: block; height: 100%; background: var(--accent); }
.category-item.expense-item .category-bar i { background: var(--danger); }
.add-heading { margin: 5px 3px 24px; }
.transaction-form { display: grid; gap: 18px; }
.segmented, .board-switch { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 15px; background: color-mix(in srgb, var(--text) 7%, transparent); }
.segment, .board-switch button { padding: 11px 10px; border: 0; border-radius: 11px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 650; }
.segment.active, .board-switch button.active { color: var(--text); background: var(--surface); box-shadow: 0 3px 12px color-mix(in srgb, var(--text) 8%, transparent); }
.transaction-form label { display: grid; gap: 8px; font-size: 12px; font-weight: 700; }
.transaction-form label > span { margin-left: 4px; }
.transaction-form em { color: var(--muted); font-size: 10px; font-style: normal; font-weight: 500; }
.transaction-form input, .transaction-form select { width: 100%; min-height: 50px; padding: 0 15px; outline: none; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); }
.transaction-form input:focus, .transaction-form select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); }
.amount-field > div { position: relative; }
.amount-field input { height: 78px; padding-right: 54px; font-size: 35px; font-weight: 750; letter-spacing: -.04em; }
.amount-field b { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 24px; }
.primary-button { min-height: 54px; margin-top: 3px; border: 0; border-radius: 16px; background: var(--accent); color: var(--accent-text); font-weight: 750; box-shadow: 0 10px 25px color-mix(in srgb, var(--accent) 28%, transparent); }
.form-hint { margin: -8px 15px 0; color: var(--muted); font-size: 10px; line-height: 1.45; text-align: center; }
.hidden { display: none !important; }
.board-heading { align-items: center; margin-top: 5px; margin-bottom: 20px; }
.icon-button { width: 42px; height: 42px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--link); font-size: 21px; }
.board-switch { margin-bottom: 13px; }
.leaderboard-list { overflow: hidden; min-height: 100px; border: 1px solid var(--line); border-radius: 22px; background: var(--surface); }
.leaderboard-list.loading { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
.leader-row { display: grid; grid-template-columns: 25px 42px 1fr auto; align-items: center; gap: 10px; padding: 13px 14px; border-bottom: 1px solid var(--line); }
.leader-row:last-child { border-bottom: 0; }
.leader-row.me { background: color-mix(in srgb, var(--accent) 9%, var(--surface)); }
.rank { color: var(--muted); text-align: center; font-size: 12px; font-weight: 750; }
.leader-avatar { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; background: color-mix(in srgb, var(--accent) 16%, var(--surface)); }
.avatar-fallback { display: grid; place-items: center; color: var(--accent); font-weight: 800; }
.leader-name { min-width: 0; }
.leader-name strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.leader-name small { color: var(--muted); font-size: 10px; }
.leader-percent { font-size: 15px; font-weight: 850; color: var(--accent); }
.leader-percent.negative { color: var(--danger); }
.friend-hint { margin: 13px 18px; color: var(--muted); font-size: 11px; line-height: 1.5; text-align: center; }
.bottom-nav { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 8px max(15px, calc((100vw - 560px) / 2)) max(8px, env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(18px); }
.bottom-nav button { display: grid; place-items: center; gap: 2px; padding: 3px; border: 0; background: transparent; color: var(--muted); font-size: 9px; font-weight: 650; }
.bottom-nav button > span { font-size: 22px; line-height: 30px; }
.bottom-nav button.active { color: var(--accent); }
.bottom-nav .add-mark { display: grid; place-items: center; width: 42px; height: 32px; border-radius: 12px; background: var(--accent); color: var(--accent-text); }
.toast { position: fixed; z-index: 20; left: 50%; bottom: calc(92px + env(safe-area-inset-bottom)); max-width: calc(100% - 36px); padding: 11px 16px; border-radius: 12px; transform: translate(-50%, 20px); background: var(--text); color: var(--bg); opacity: 0; pointer-events: none; transition: .2s ease; font-size: 12px; box-shadow: var(--shadow); }
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@media (min-width: 600px) {
body { padding-top: 28px; }
.bottom-nav { left: 50%; bottom: 14px; width: 540px; border: 1px solid var(--line); border-radius: 22px; transform: translateX(-50%); padding: 8px 22px; box-shadow: var(--shadow); }
}

3397
static/telegram-web-app.js Normal file

File diff suppressed because it is too large Load diff

75
tests/test_core.py Normal file
View file

@ -0,0 +1,75 @@
from pathlib import Path
import pytest
from app.bot import parse_transaction
from app.db import Database
def make_database(tmp_path: Path) -> Database:
database = Database(tmp_path / "test.sqlite3")
database.initialize()
return database
def add_user(database: Database, user_id: int, name: str) -> None:
database.upsert_user(
user_id=user_id,
first_name=name,
last_name=None,
username=None,
)
def test_transaction_and_leaderboard(tmp_path: Path) -> None:
database = make_database(tmp_path)
add_user(database, 1, "Аня")
item = parse_transaction(
'{"v":1,"type":"transaction","kind":"income","amount":"1000.50","category":"Зарплата","occurred_at":"2026-01-01T00:00:00Z"}'
)
database.add_transaction(1, item)
expense = parse_transaction(
'{"v":1,"type":"transaction","kind":"expense","amount":"250.25","category":"Еда","occurred_at":"2026-01-02T00:00:00Z"}'
)
database.add_transaction(1, expense)
summary = database.summary(1)
assert summary is not None
assert summary["income_cents"] == 100_050
assert summary["expense_cents"] == 25_025
assert summary["saved_percent"] == 75.0
assert database.leaderboard()[0]["nickname"] == "Аня"
def test_friendship_is_symmetric_and_idempotent(tmp_path: Path) -> None:
database = make_database(tmp_path)
add_user(database, 3, "Три")
add_user(database, 7, "Семь")
assert database.add_friendship(7, 3)
assert not database.add_friendship(3, 7)
assert database.friend_ids(3) == [7]
assert database.friend_ids(7) == [3]
def test_filtered_leaderboard(tmp_path: Path) -> None:
database = make_database(tmp_path)
for user_id in (1, 2, 3):
add_user(database, user_id, str(user_id))
result = database.leaderboard(user_ids=[1, 3])
assert [item["user_id"] for item in result] == [1, 3]
@pytest.mark.parametrize(
"payload",
[
"not-json",
'{"v":1,"type":"transaction","kind":"other","amount":"10","category":"Еда"}',
'{"v":1,"type":"transaction","kind":"income","amount":"-1","category":"Зарплата"}',
'{"v":1,"type":"transaction","kind":"income","amount":"10","category":""}',
],
)
def test_rejects_invalid_webapp_payload(payload: str) -> None:
with pytest.raises(ValueError):
parse_transaction(payload)

1352
uv.lock generated Normal file

File diff suppressed because it is too large Load diff