From 2ce385554fe2b42c27b2a1bbb986ac2a3a7dba64 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 14 Jul 2026 12:21:12 +0700 Subject: [PATCH] Initial release of family finance Telegram bot --- .dockerignore | 10 + .env.example | 11 + .gitignore | 9 + Dockerfile | 20 + README.md | 158 +++++++ app/__init__.py | 2 + app/__main__.py | 68 +++ app/categories.py | 129 ++++++ app/config.py | 44 ++ app/database.py | 884 +++++++++++++++++++++++++++++++++++++++ app/domain.py | 88 ++++ app/handlers.py | 592 ++++++++++++++++++++++++++ app/keyboards.py | 152 +++++++ app/messages.py | 210 ++++++++++ app/parser.py | 84 ++++ app/periods.py | 18 + app/scheduler.py | 80 ++++ compose.yaml | 12 + install.sh | 251 +++++++++++ pyproject.toml | 25 ++ tests/test_categories.py | 39 ++ tests/test_database.py | 257 ++++++++++++ tests/test_parser.py | 41 ++ tests/test_periods.py | 19 + tests/test_scheduler.py | 51 +++ 25 files changed, 3254 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/categories.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/domain.py create mode 100644 app/handlers.py create mode 100644 app/keyboards.py create mode 100644 app/messages.py create mode 100644 app/parser.py create mode 100644 app/periods.py create mode 100644 app/scheduler.py create mode 100644 compose.yaml create mode 100644 install.sh create mode 100644 pyproject.toml create mode 100644 tests/test_categories.py create mode 100644 tests/test_database.py create mode 100644 tests/test_parser.py create mode 100644 tests/test_periods.py create mode 100644 tests/test_scheduler.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0bf86f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.env +.venv +__pycache__ +*.py[cod] +.pytest_cache +tests +data + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c2efc75 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Получите токен у @BotFather +BOT_TOKEN=123456789:replace_me + +# IANA-таймзона и время автоматического дневного отчёта +BOT_TIMEZONE=Asia/Novosibirsk +DAILY_REPORT_TIME=21:00 + +# В Docker оставьте это значение без изменений +DATABASE_PATH=/app/data/finance.db +LOG_LEVEL=INFO + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..195d40a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +*.egg-info/ +data/*.db +data/*.db-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99cb79e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY app ./app + +RUN pip install --upgrade pip && pip install . && \ + groupadd --system financebot && \ + useradd --system --gid financebot --home-dir /app financebot && \ + mkdir -p /app/data && chown -R financebot:financebot /app/data + +USER financebot + +CMD ["python", "-m", "app"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6042de --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Финансовый Telegram-бот + +Аккуратный бот для повседневного учёта личных финансов. Расход записывается одной строкой — `467 ярче` или `3000 бензин`. Доход — через `+ 85000 зарплата` или `доход 5000 подработка`. + +## Что уже работает + +- автоматическое определение категорий: Продукты, Развлечения, Машина, Кредиты, Ремонт и Другое; +- доходы, расходы и точные суммы с копейками; +- изменение категории и удаление только что добавленной записи; +- итоги за текущий месяц и за всё время; +- аналитика по категориям с процентами и визуальными полосами — за месяц или всё время; +- последние 10 операций; +- ежедневный отчёт: траты и доходы за день плюс накопительный итог месяца; +- отдельные данные для каждого Telegram-пользователя; +- защищённый семейный бюджет для двух супругов с подтверждением владельца; +- мгновенные уведомления супругу и в подключённый семейный Telegram-чат; +- один общий вечерний отчёт в семейный чат; +- общий долг: доходы автоматически уменьшают его, расходы увеличивают; +- категории и подкатегории с возможностью добавлять собственные; +- SQLite в режиме WAL и защита от повторного дневного отчёта; +- готовый Docker-запуск на сервере. + +## Автоматическая установка на сервер + +Для Ubuntu или Debian используйте интерактивный установщик `install.sh`. Он скачивает актуальную копию проекта с публичной папки Яндекс.Диска, при необходимости устанавливает Docker, спрашивает токен и настройки отчётов, после чего запускает бота. + +Скопируйте `install.sh` на сервер и выполните: + +```bash +sudo bash install.sh +``` + +Или скачайте сам установщик из публичной папки прямо на сервере: + +```bash +PUBLIC_URL='https://disk.yandex.ru/d/hhbDTO08CB030Q' +INSTALLER_URL="$(curl -fsSG 'https://cloud-api.yandex.net/v1/disk/public/resources/download' \ + --data-urlencode "public_key=$PUBLIC_URL" --data-urlencode 'path=/install.sh' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["href"])')" +curl -fsSL "$INSTALLER_URL" -o install.sh +sudo bash install.sh +``` + +Установщик спросит: + +- папку установки; +- токен, полученный у [@BotFather](https://t.me/BotFather); +- часовой пояс; +- время ежедневного отчёта. + +Повторный запуск обновляет код, но сохраняет `.env` и базу данных в Docker-томе. Публичная ссылка проекта уже записана в скрипте. При необходимости её можно переопределить: + +```bash +sudo FINANCE_BOT_PUBLIC_URL="https://disk.yandex.ru/d/другая-ссылка" bash install.sh +``` + +## Ручной запуск на сервере + +Понадобятся Docker и Docker Compose. + +1. Создайте бота через [@BotFather](https://t.me/BotFather) и скопируйте токен. +2. На сервере скопируйте `.env.example` в `.env`. +3. В `.env` замените `BOT_TOKEN`, при необходимости настройте таймзону и время отчёта. +4. Запустите контейнер: + +```bash +docker compose up -d --build +``` + +Посмотреть состояние и журнал: + +```bash +docker compose ps +docker compose logs -f finance-bot +``` + +Данные сохраняются в Docker-томе `finance-data` и не исчезают при пересборке контейнера. + +## Настройки + +| Переменная | Пример | Назначение | +|---|---|---| +| `BOT_TOKEN` | `123:ABC...` | токен от BotFather | +| `BOT_TIMEZONE` | `Asia/Novosibirsk` | таймзона операций и отчётов | +| `DAILY_REPORT_TIME` | `21:00` | локальное время дневного отчёта | +| `DATABASE_PATH` | `/app/data/finance.db` | путь к SQLite | +| `LOG_LEVEL` | `INFO` | уровень журналирования | + +## Семейный бюджет и общий чат + +1. Первый супруг открывает кнопку **«Семья»** и выбирает **«Создать общий бюджет»**. +2. Второй супруг запускает этого же бота, открывает **«Семья»**, нажимает **«Ввести код супруга»** и отправляет полученный код. +3. Код не даёт доступ автоматически: создатель бюджета получает запрос с кнопками **«Подтвердить»** и **«Отклонить»**. +4. После подтверждения итоги, аналитика и история становятся общими. Новые записи мгновенно отправляются второму супругу. +5. Для общего журнала создайте Telegram-группу, добавьте туда бота. Создатель семейного бюджета должен быть администратором этой группы и отправить в ней команду `/family_chat`. + +После привязки каждая новая операция обоих супругов дублируется в группу. В заданное время туда также приходит один общий дневной отчёт. Посторонний человек не сможет подключиться только по коду: требуется ручное подтверждение владельца, семейный бюджет ограничен двумя участниками, а привязать групповой чат может только создатель бюджета, являющийся его администратором. + +### Общий долг + +После создания семейного бюджета откройте кнопку **«💳 Общий долг»** и укажите текущую сумму. Сделать это может только создатель бюджета, второй супруг видит результат. + +Например, исходный долг составляет `530713`. После записи `+ 400000 зарплата` бот покажет долг `130713`. Следующая запись `5000 продукты` увеличит его до `135713`. + +Расчёт ведётся по операциям после установки исходной суммы: + +- каждый расход увеличивает долг; +- каждый доход уменьшает долг; +- удаление ошибочной операции автоматически пересчитывает сумму; +- если доходы превысят долг, бот покажет разницу как семейный резерв; +- текущий долг или резерв отображается в подтверждениях, семейном чате и дневном отчёте. + +Повторная установка текущей суммы начинает новый расчёт с указанного значения, не меняя историю доходов и расходов. + +### Категории и подкатегории + +В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию. + +При создании бюджета бот автоматически добавляет базовое дерево, например: + +- Машина → Бензин, Ремонт, Запчасти, Мойка и парковка, Страховка; +- Продукты → Еда, Алкоголь, Кафе и рестораны, Бытовые товары; +- Развлечения → Кино и театр, Игры, Подписки, Хобби; +- Кредиты → Ипотека, Кредиты, Рассрочки; +- Ремонт → Материалы, Мебель, Работы, Инструменты. + +Стандартные подкатегории определяются автоматически: запись `3000 бензин` попадёт в **Машина → Бензин**, а `500 пиво` — в **Продукты → Алкоголь**. + +Чтобы создать свою структуру, нажмите **«🗂 Категории»**, затем **«Добавить категорию»** или **«Добавить подкатегорию»**. Новые пользовательские категории выбираются вручную при изменении записи. + +После добавления расхода нажмите **«Изменить категорию»**. Можно выбрать как основную категорию, так и любую подкатегорию. Это работает и для записей, которые первоначально попали в **«Другое»**. Изменить категорию может только автор операции; супруг и семейный чат сразу получают уведомление о переносе. + +## Локальная разработка + +Нужен Python 3.11 или новее. + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +cp .env.example .env # Windows: copy .env.example .env +``` + +Переменные из `.env` нужно загрузить в окружение, затем запустить: + +```bash +python -m app +``` + +Тесты: + +```bash +pytest +``` + +## Как расширять категории + +Названия, иконки и ключевые слова находятся в `app/categories.py`. Новые ключевые слова можно добавлять без изменения базы данных. Суммы хранятся целым числом копеек, поэтому арифметика не накапливает ошибки `float`. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..85011a9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Personal finance Telegram bot.""" + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..60c09ca --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +import logging + +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.types import ( + BotCommand, + BotCommandScopeAllGroupChats, + BotCommandScopeAllPrivateChats, +) + +from app.config import Settings +from app.database import Database +from app.handlers import create_router +from app.scheduler import daily_report_loop, stop_task + + +async def main() -> None: + settings = Settings.from_env() + logging.basicConfig( + level=getattr(logging, settings.log_level, logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + + database = Database(settings.database_path) + await database.initialize() + bot = Bot( + token=settings.bot_token, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + dispatcher = Dispatcher() + dispatcher.include_router(create_router(database, settings)) + + await bot.set_my_commands( + [ + BotCommand(command="start", description="Открыть главное меню"), + BotCommand(command="month", description="Итог за месяц"), + BotCommand(command="total", description="Итог за всё время"), + BotCommand(command="analytics", description="Расходы по категориям"), + BotCommand(command="recent", description="Последние записи"), + BotCommand(command="family", description="Семейный бюджет"), + BotCommand(command="debt", description="Общий долг"), + BotCommand(command="categories", description="Категории и подкатегории"), + BotCommand(command="help", description="Как пользоваться ботом"), + ], + scope=BotCommandScopeAllPrivateChats(), + ) + await bot.set_my_commands( + [BotCommand(command="family_chat", description="Подключить семейный чат")], + scope=BotCommandScopeAllGroupChats(), + ) + + report_task = asyncio.create_task(daily_report_loop(bot, database)) + try: + await dispatcher.start_polling( + bot, + allowed_updates=dispatcher.resolve_used_update_types(), + ) + finally: + await stop_task(report_task) + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/categories.py b/app/categories.py new file mode 100644 index 0000000..0e00daf --- /dev/null +++ b/app/categories.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import re + +from app.domain import Category + + +CATEGORY_META: dict[Category, tuple[str, str]] = { + Category.PRODUCTS: ("🛒", "Продукты"), + Category.ENTERTAINMENT: ("🎉", "Развлечения"), + Category.CAR: ("🚗", "Машина"), + Category.CREDITS: ("🏦", "Кредиты"), + Category.RENOVATION: ("🛠", "Ремонт"), + Category.OTHER: ("📦", "Другое"), + Category.INCOME: ("💚", "Доход"), +} + +# Сначала проверяются более специфичные слова. Список легко расширять без миграции БД. +CATEGORY_KEYWORDS: dict[Category, tuple[str, ...]] = { + Category.PRODUCTS: ( + "ярче", "мария ра", "пятерочка", "пятёрочка", "магнит", "лента", + "ашан", "metro", "продукт", "еда", "молоко", "хлеб", "мясо", + "овощ", "фрукт", "кофе", "кафе", "ресторан", "доставка еды", + "алкоголь", "пиво", "вино", "супермаркет", + ), + Category.ENTERTAINMENT: ( + "кино", "театр", "концерт", "бар", "клуб", "игра", "steam", + "подписка", "музей", "развлеч", "боулинг", "караоке", "хобби", + ), + Category.CAR: ( + "бензин", "топливо", "азс", "газпромнефть", "лукойл", "g-drive", + "шиномонтаж", "автосервис", "мойка", "запчаст", "масло", "осаго", + "парковка", "штраф гибдд", "машин", "авто", + ), + Category.CREDITS: ( + "кредит", "ипотек", "долг", "рассроч", "процент банку", "заём", "займ", + ), + Category.RENOVATION: ( + "ремонт квартир", "стройматериал", "обои", "краска", "ламинат", + "сантехник", "электрик", "мебель", "инструмент", "стройка", "леруа", "ремонт", + ), +} + + +def normalize(text: str) -> str: + text = text.casefold().replace("ё", "е") + return re.sub(r"[^a-zа-я0-9]+", " ", text).strip() + + +def classify(description: str) -> Category: + normalized_text = normalize(description) + normalized = f" {normalized_text} " + words = normalized_text.split() + for category, keywords in CATEGORY_KEYWORDS.items(): + for keyword in keywords: + normalized_keyword = normalize(keyword) + is_phrase = " " in normalized_keyword + exact_phrase = is_phrase and f" {normalized_keyword} " in normalized + word_match = not is_phrase and any( + word == normalized_keyword + or (len(normalized_keyword) >= 4 and word.startswith(normalized_keyword)) + for word in words + ) + if exact_phrase or word_match: + return category + return Category.OTHER + + +def category_label(category: Category) -> str: + icon, name = CATEGORY_META[category] + return f"{icon} {name}" + + +EXPENSE_CATEGORIES = tuple(category for category in Category if category is not Category.INCOME) + + +# system_key, icon, visible name, keywords. These are seeded for every family budget. +DEFAULT_SUBCATEGORIES: dict[Category, tuple[tuple[str, str, str, tuple[str, ...]], ...]] = { + Category.PRODUCTS: ( + ("products_food", "🥦", "Еда", ("продукт", "еда", "ярче", "магнит", "пятер", "лента", "молоко", "хлеб", "мясо", "овощ", "фрукт")), + ("products_alcohol", "🍷", "Алкоголь", ("алкоголь", "пиво", "вино", "водка")), + ("products_cafe", "☕", "Кафе и рестораны", ("кафе", "ресторан", "кофе", "доставка еды")), + ("products_household", "🧻", "Бытовые товары", ("бытовая химия", "хозтовар", "порошок", "салфетк")), + ), + Category.ENTERTAINMENT: ( + ("entertainment_cinema", "🎬", "Кино и театр", ("кино", "театр", "концерт", "музей")), + ("entertainment_games", "🎮", "Игры", ("игра", "steam", "playstation", "xbox")), + ("entertainment_subscriptions", "📺", "Подписки", ("подписка", "кинопоиск", "ivi", "netflix")), + ("entertainment_hobby", "🎨", "Хобби", ("хобби", "боулинг", "караоке")), + ), + Category.CAR: ( + ("car_fuel", "⛽", "Бензин", ("бензин", "топливо", "азс", "газпромнефть", "лукойл", "g drive")), + ("car_repair", "🔧", "Ремонт", ("автосервис", "ремонт машины", "ремонт авто", "шиномонтаж")), + ("car_parts", "⚙️", "Запчасти", ("запчаст", "масло", "аккумулятор", "шина", "резина")), + ("car_service", "🅿️", "Мойка и парковка", ("мойка", "парковка")), + ("car_insurance", "🛡", "Страховка", ("осаго", "каско", "страховка авто")), + ), + Category.CREDITS: ( + ("credits_mortgage", "🏠", "Ипотека", ("ипотек",)), + ("credits_bank", "💳", "Кредиты", ("кредит", "процент банку")), + ("credits_installment", "📆", "Рассрочки", ("рассроч",)), + ), + Category.RENOVATION: ( + ("renovation_materials", "🧱", "Материалы", ("стройматериал", "обои", "краска", "ламинат", "леруа")), + ("renovation_furniture", "🪑", "Мебель", ("мебель", "диван", "шкаф", "стол")), + ("renovation_work", "👷", "Работы", ("сантехник", "электрик", "ремонт квартир", "стройка")), + ("renovation_tools", "🧰", "Инструменты", ("инструмент", "дрель", "шуруповерт")), + ), + Category.OTHER: (), +} + + +def suggest_subcategory(category: Category, description: str) -> str | None: + normalized_text = normalize(description) + words = normalized_text.split() + padded = f" {normalized_text} " + for system_key, _icon, _name, keywords in DEFAULT_SUBCATEGORIES.get(category, ()): + for keyword in keywords: + normalized_keyword = normalize(keyword) + if " " in normalized_keyword: + if f" {normalized_keyword} " in padded: + return system_key + elif any( + word == normalized_keyword + or (len(normalized_keyword) >= 4 and word.startswith(normalized_keyword)) + for word in words + ): + return system_key + return None diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..52b7794 --- /dev/null +++ b/app/config.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import time +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +@dataclass(frozen=True, slots=True) +class Settings: + bot_token: str + database_path: Path + timezone: ZoneInfo + daily_report_time: time + log_level: str = "INFO" + + @classmethod + def from_env(cls) -> "Settings": + token = os.getenv("BOT_TOKEN", "").strip() + if not token: + raise RuntimeError("Переменная BOT_TOKEN не задана") + + timezone_name = os.getenv("BOT_TIMEZONE", "Asia/Novosibirsk").strip() + try: + timezone = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise RuntimeError(f"Неизвестная таймзона BOT_TIMEZONE: {timezone_name}") from exc + + report_time_raw = os.getenv("DAILY_REPORT_TIME", "21:00").strip() + try: + hour, minute = (int(part) for part in report_time_raw.split(":", maxsplit=1)) + report_time = time(hour=hour, minute=minute) + except (ValueError, TypeError) as exc: + raise RuntimeError("DAILY_REPORT_TIME должен иметь формат ЧЧ:ММ") from exc + + return cls( + bot_token=token, + database_path=Path(os.getenv("DATABASE_PATH", "data/finance.db")), + timezone=timezone, + daily_report_time=report_time, + log_level=os.getenv("LOG_LEVEL", "INFO").upper(), + ) + diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..41d9415 --- /dev/null +++ b/app/database.py @@ -0,0 +1,884 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import secrets + +import aiosqlite + +from app.categories import CATEGORY_META, DEFAULT_SUBCATEGORIES, category_label +from app.domain import ( + BudgetCategory, + Category, + FamilyInfo, + FamilyJoinRequest, + Totals, + Transaction, + TransactionKind, +) + + +class FamilyJoinError(ValueError): + pass + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + + async def initialize(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + async with aiosqlite.connect(self.path) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") + await db.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + chat_id INTEGER NOT NULL, + username TEXT, + full_name TEXT NOT NULL, + timezone TEXT NOT NULL, + report_time TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS households ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invite_code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(user_id), + notification_chat_id INTEGER, + notification_chat_title TEXT, + debt_base_kopecks INTEGER, + debt_started_at TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS household_members ( + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(user_id) ON DELETE CASCADE, + joined_at TEXT NOT NULL, + PRIMARY KEY (household_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS budget_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📁', + parent_id INTEGER REFERENCES budget_categories(id) ON DELETE RESTRICT, + system_key TEXT, + created_by INTEGER REFERENCES users(user_id), + created_at TEXT NOT NULL, + UNIQUE (household_id, system_key) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_category_name + ON budget_categories(household_id, COALESCE(parent_id, 0), lower(name)); + + CREATE TABLE IF NOT EXISTS pending_family_joins ( + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(user_id) ON DELETE CASCADE, + requested_at TEXT NOT NULL, + PRIMARY KEY (household_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + amount_kopecks INTEGER NOT NULL CHECK (amount_kopecks > 0), + description TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('expense', 'income')), + category TEXT NOT NULL, + household_id INTEGER REFERENCES households(id), + budget_category_id INTEGER REFERENCES budget_categories(id), + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_transactions_user_date + ON transactions(user_id, occurred_at); + + CREATE TABLE IF NOT EXISTS daily_reports ( + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + local_date TEXT NOT NULL, + sent_at TEXT NOT NULL, + PRIMARY KEY (user_id, local_date) + ); + + CREATE TABLE IF NOT EXISTS family_daily_reports ( + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + local_date TEXT NOT NULL, + sent_at TEXT NOT NULL, + PRIMARY KEY (household_id, local_date) + ); + """ + ) + columns_cursor = await db.execute("PRAGMA table_info(transactions)") + columns = {row[1] for row in await columns_cursor.fetchall()} + if "household_id" not in columns: + await db.execute( + "ALTER TABLE transactions ADD COLUMN household_id INTEGER REFERENCES households(id)" + ) + if "budget_category_id" not in columns: + await db.execute( + "ALTER TABLE transactions ADD COLUMN budget_category_id INTEGER " + "REFERENCES budget_categories(id)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_transactions_household_date " + "ON transactions(household_id, occurred_at)" + ) + household_columns_cursor = await db.execute("PRAGMA table_info(households)") + household_columns = {row[1] for row in await household_columns_cursor.fetchall()} + if "notification_chat_id" not in household_columns: + await db.execute("ALTER TABLE households ADD COLUMN notification_chat_id INTEGER") + if "notification_chat_title" not in household_columns: + await db.execute("ALTER TABLE households ADD COLUMN notification_chat_title TEXT") + if "debt_base_kopecks" not in household_columns: + await db.execute("ALTER TABLE households ADD COLUMN debt_base_kopecks INTEGER") + if "debt_started_at" not in household_columns: + await db.execute("ALTER TABLE households ADD COLUMN debt_started_at TEXT") + household_cursor = await db.execute("SELECT id FROM households") + for household_row in await household_cursor.fetchall(): + await self._seed_default_categories(db, int(household_row[0])) + await db.execute( + """ + UPDATE transactions + SET budget_category_id=( + SELECT bc.id FROM budget_categories bc + WHERE bc.household_id=transactions.household_id + AND bc.system_key=transactions.category + ) + WHERE household_id IS NOT NULL AND budget_category_id IS NULL + """ + ) + await db.commit() + + async def upsert_user( + self, + *, + user_id: int, + chat_id: int, + username: str | None, + full_name: str, + timezone: str, + report_time: str, + now: datetime, + ) -> None: + timestamp = now.isoformat() + async with aiosqlite.connect(self.path) as db: + await db.execute( + """ + INSERT INTO users ( + user_id, chat_id, username, full_name, timezone, + report_time, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + chat_id=excluded.chat_id, + username=excluded.username, + full_name=excluded.full_name, + timezone=excluded.timezone, + report_time=excluded.report_time, + updated_at=excluded.updated_at + """, + (user_id, chat_id, username, full_name, timezone, report_time, timestamp, timestamp), + ) + await db.commit() + + async def add_transaction( + self, + *, + user_id: int, + amount_kopecks: int, + description: str, + kind: TransactionKind, + category: Category, + budget_category_id: int | None = None, + occurred_at: datetime, + ) -> Transaction: + now = datetime.now(tz=occurred_at.tzinfo) + async with aiosqlite.connect(self.path) as db: + user_cursor = await db.execute( + """ + SELECT hm.household_id, u.full_name + FROM users u + LEFT JOIN household_members hm ON hm.user_id=u.user_id + WHERE u.user_id=? + """, + (user_id,), + ) + user_row = await user_cursor.fetchone() + household_id = user_row[0] if user_row else None + author_name = str(user_row[1]) if user_row else None + cursor = await db.execute( + """ + INSERT INTO transactions ( + user_id, amount_kopecks, description, kind, category, household_id, + budget_category_id, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + amount_kopecks, + description, + kind.value, + category.value, + household_id, + budget_category_id, + occurred_at.isoformat(), + now.isoformat(), + ), + ) + await db.commit() + transaction_id = int(cursor.lastrowid) + transaction = await self.get_transaction(transaction_id, user_id) + if transaction is None: + raise RuntimeError("Операция не сохранилась") + return transaction + + async def get_transaction(self, transaction_id: int, user_id: int) -> Transaction | None: + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT t.*, u.full_name AS author_name, + c.name AS budget_category_name, c.icon AS budget_category_icon, + p.name AS parent_category_name, p.icon AS parent_category_icon + FROM transactions t JOIN users u ON u.user_id=t.user_id + LEFT JOIN budget_categories c ON c.id=t.budget_category_id + LEFT JOIN budget_categories p ON p.id=c.parent_id + WHERE t.id=? AND t.user_id=? + """, + (transaction_id, user_id), + ) + row = await cursor.fetchone() + return self._row_to_transaction(row) if row else None + + async def update_category( + self, transaction_id: int, user_id: int, category: Category + ) -> bool: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + "UPDATE transactions SET category=? WHERE id=? AND user_id=? AND kind='expense'", + (category.value, transaction_id, user_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def delete_transaction(self, transaction_id: int, user_id: int) -> bool: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + "DELETE FROM transactions WHERE id=? AND user_id=?", + (transaction_id, user_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def totals( + self, user_id: int, start: datetime | None = None, end: datetime | None = None + ) -> Totals: + where = [self._scope_clause("t")] + params: list[object] = [user_id, user_id] + if start is not None: + where.append("occurred_at >= ?") + params.append(start.isoformat()) + if end is not None: + where.append("occurred_at < ?") + params.append(end.isoformat()) + query = f""" + SELECT + COALESCE(SUM(CASE WHEN kind='income' THEN amount_kopecks ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind='expense' THEN amount_kopecks ELSE 0 END), 0) + FROM transactions t WHERE {' AND '.join(where)} + """ + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute(query, params) + row = await cursor.fetchone() + return Totals(income=int(row[0]), expense=int(row[1])) + + async def category_totals( + self, user_id: int, start: datetime | None = None, end: datetime | None = None + ) -> dict[str, int]: + where = [self._scope_clause("t"), "t.kind='expense'"] + params: list[object] = [user_id, user_id] + if start is not None: + where.append("occurred_at>=?") + params.append(start.isoformat()) + if end is not None: + where.append("occurred_at list[Transaction]: + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + f""" + SELECT t.*, u.full_name AS author_name, + c.name AS budget_category_name, c.icon AS budget_category_icon, + p.name AS parent_category_name, p.icon AS parent_category_icon + FROM transactions t JOIN users u ON u.user_id=t.user_id + LEFT JOIN budget_categories c ON c.id=t.budget_category_id + LEFT JOIN budget_categories p ON p.id=c.parent_id + WHERE {self._scope_clause("t")} + ORDER BY t.occurred_at DESC, t.id DESC LIMIT ? + """, + (user_id, user_id, limit), + ) + rows = await cursor.fetchall() + return [self._row_to_transaction(row) for row in rows] + + async def create_family(self, user_id: int, now: datetime) -> FamilyInfo: + async with aiosqlite.connect(self.path) as db: + await db.execute("PRAGMA foreign_keys=ON") + await db.execute("BEGIN IMMEDIATE") + existing = await self._family_info_in_connection(db, user_id) + if existing is not None: + await db.rollback() + return existing + + for _ in range(10): + code = "FIN-" + "".join( + secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(6) + ) + try: + cursor = await db.execute( + "INSERT INTO households(invite_code, created_by, created_at) VALUES (?, ?, ?)", + (code, user_id, now.isoformat()), + ) + break + except aiosqlite.IntegrityError: + continue + else: + await db.rollback() + raise RuntimeError("Не удалось создать уникальный код семьи") + + household_id = int(cursor.lastrowid) + await self._seed_default_categories(db, household_id) + await db.execute( + "INSERT INTO household_members(household_id, user_id, joined_at) VALUES (?, ?, ?)", + (household_id, user_id, now.isoformat()), + ) + await db.execute( + "UPDATE transactions SET household_id=? WHERE user_id=? AND household_id IS NULL", + (household_id, user_id), + ) + await db.execute( + """ + UPDATE transactions + SET budget_category_id=( + SELECT id FROM budget_categories + WHERE household_id=? AND system_key=transactions.category + ) + WHERE user_id=? AND household_id=? AND budget_category_id IS NULL + """, + (household_id, user_id, household_id), + ) + await db.commit() + info = await self.family_info(user_id) + if info is None: + raise RuntimeError("Семья не сохранилась") + return info + + async def request_family_join( + self, user_id: int, invite_code: str, now: datetime + ) -> FamilyJoinRequest: + code = invite_code.strip().upper() + async with aiosqlite.connect(self.path) as db: + await db.execute("PRAGMA foreign_keys=ON") + await db.execute("BEGIN IMMEDIATE") + if await self._family_info_in_connection(db, user_id) is not None: + await db.rollback() + raise FamilyJoinError("Вы уже состоите в семейном бюджете") + cursor = await db.execute( + """ + SELECT h.id, owner.chat_id, candidate.full_name, + (SELECT COUNT(*) FROM household_members WHERE household_id=h.id) + FROM households h + JOIN users owner ON owner.user_id=h.created_by + JOIN users candidate ON candidate.user_id=? + WHERE h.invite_code=? + """, + (user_id, code), + ) + row = await cursor.fetchone() + if row is None: + await db.rollback() + raise FamilyJoinError("Код приглашения не найден") + if int(row[3]) >= 2: + await db.rollback() + raise FamilyJoinError("В этом семейном бюджете уже два участника") + await db.execute( + """ + INSERT INTO pending_family_joins(household_id, user_id, requested_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + household_id=excluded.household_id, + requested_at=excluded.requested_at + """, + (row[0], user_id, now.isoformat()), + ) + await db.commit() + return FamilyJoinRequest( + candidate_user_id=user_id, + candidate_name=str(row[2]), + owner_chat_id=int(row[1]), + ) + + async def resolve_family_join( + self, + *, + owner_user_id: int, + candidate_user_id: int, + approve: bool, + now: datetime, + ) -> tuple[bool, int, FamilyInfo | None]: + async with aiosqlite.connect(self.path) as db: + await db.execute("PRAGMA foreign_keys=ON") + await db.execute("BEGIN IMMEDIATE") + cursor = await db.execute( + """ + SELECT p.household_id, u.chat_id + FROM pending_family_joins p + JOIN households h ON h.id=p.household_id + JOIN users u ON u.user_id=p.user_id + WHERE h.created_by=? AND p.user_id=? + """, + (owner_user_id, candidate_user_id), + ) + row = await cursor.fetchone() + if row is None: + await db.rollback() + raise FamilyJoinError("Запрос уже обработан или не найден") + household_id, candidate_chat_id = int(row[0]), int(row[1]) + await db.execute( + "DELETE FROM pending_family_joins WHERE household_id=? AND user_id=?", + (household_id, candidate_user_id), + ) + if not approve: + await db.commit() + return False, candidate_chat_id, None + count_cursor = await db.execute( + "SELECT COUNT(*) FROM household_members WHERE household_id=?", + (household_id,), + ) + if int((await count_cursor.fetchone())[0]) >= 2: + await db.rollback() + raise FamilyJoinError("В семейном бюджете уже два участника") + await db.execute( + "INSERT INTO household_members(household_id, user_id, joined_at) VALUES (?, ?, ?)", + (household_id, candidate_user_id, now.isoformat()), + ) + await db.execute( + "UPDATE transactions SET household_id=? WHERE user_id=? AND household_id IS NULL", + (household_id, candidate_user_id), + ) + await db.execute( + """ + UPDATE transactions + SET budget_category_id=( + SELECT id FROM budget_categories + WHERE household_id=? AND system_key=transactions.category + ) + WHERE user_id=? AND household_id=? AND budget_category_id IS NULL + """, + (household_id, candidate_user_id, household_id), + ) + await db.commit() + return True, candidate_chat_id, await self.family_info(candidate_user_id) + + async def bind_family_chat( + self, owner_user_id: int, chat_id: int, chat_title: str + ) -> FamilyInfo: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + UPDATE households + SET notification_chat_id=?, notification_chat_title=? + WHERE created_by=? + """, + (chat_id, chat_title, owner_user_id), + ) + await db.commit() + if cursor.rowcount == 0: + raise FamilyJoinError("Привязать чат может только создатель семейного бюджета") + info = await self.family_info(owner_user_id) + if info is None: + raise FamilyJoinError("Семейный бюджет не найден") + return info + + async def set_family_debt( + self, owner_user_id: int, amount_kopecks: int, now: datetime + ) -> int: + if amount_kopecks < 0: + raise ValueError("Долг не может быть отрицательным") + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + UPDATE households + SET debt_base_kopecks=?, debt_started_at=? + WHERE created_by=? + """, + (amount_kopecks, now.isoformat(), owner_user_id), + ) + await db.commit() + if cursor.rowcount == 0: + raise FamilyJoinError("Изменить общий долг может только создатель бюджета") + return amount_kopecks + + async def family_debt(self, user_id: int) -> int | None: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + SELECT h.debt_base_kopecks, h.debt_started_at, + COALESCE(SUM( + CASE + WHEN t.kind='expense' THEN t.amount_kopecks + WHEN t.kind='income' THEN -t.amount_kopecks + ELSE 0 + END + ), 0) + FROM households h + JOIN household_members hm ON hm.household_id=h.id + LEFT JOIN transactions t + ON t.household_id=h.id AND t.occurred_at>h.debt_started_at + WHERE hm.user_id=? + GROUP BY h.id + """, + (user_id,), + ) + row = await cursor.fetchone() + if row is None or row[0] is None or row[1] is None: + return None + return int(row[0]) + int(row[2]) + + async def budget_categories(self, user_id: int) -> list[BudgetCategory]: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + SELECT c.id, c.name, c.icon, c.parent_id, p.name, p.icon, c.system_key + FROM household_members hm + JOIN budget_categories c ON c.household_id=hm.household_id + LEFT JOIN budget_categories p ON p.id=c.parent_id + WHERE hm.user_id=? AND (c.system_key IS NULL OR c.system_key<>'income') + ORDER BY COALESCE(p.id, c.id), c.parent_id IS NOT NULL, c.name + """, + (user_id,), + ) + rows = await cursor.fetchall() + return [ + BudgetCategory( + id=int(row[0]), + name=str(row[1]), + icon=str(row[2]), + parent_id=int(row[3]) if row[3] is not None else None, + parent_name=str(row[4]) if row[4] is not None else None, + parent_icon=str(row[5]) if row[5] is not None else None, + system_key=str(row[6]) if row[6] is not None else None, + ) + for row in rows + ] + + async def resolve_budget_category( + self, user_id: int, main_category: Category, subcategory_key: str | None + ) -> int | None: + key = subcategory_key or main_category.value + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + SELECT c.id + FROM household_members hm + JOIN budget_categories c ON c.household_id=hm.household_id + WHERE hm.user_id=? AND c.system_key=? + """, + (user_id, key), + ) + row = await cursor.fetchone() + return int(row[0]) if row else None + + async def create_budget_category( + self, + *, + user_id: int, + name: str, + parent_id: int | None, + now: datetime, + ) -> BudgetCategory: + clean_name = " ".join(name.split()).strip() + if not 2 <= len(clean_name) <= 40: + raise ValueError("Название должно содержать от 2 до 40 символов") + async with aiosqlite.connect(self.path) as db: + await db.execute("PRAGMA foreign_keys=ON") + household_cursor = await db.execute( + "SELECT household_id FROM household_members WHERE user_id=?", (user_id,) + ) + household_row = await household_cursor.fetchone() + if household_row is None: + raise FamilyJoinError("Категории настраиваются внутри семейного бюджета") + household_id = int(household_row[0]) + count_cursor = await db.execute( + "SELECT COUNT(*) FROM budget_categories WHERE household_id=?", (household_id,) + ) + if int((await count_cursor.fetchone())[0]) >= 60: + raise ValueError("Достигнут лимит в 60 категорий и подкатегорий") + if parent_id is not None: + parent_cursor = await db.execute( + """ + SELECT 1 FROM budget_categories + WHERE id=? AND household_id=? AND parent_id IS NULL + AND (system_key IS NULL OR system_key<>'income') + """, + (parent_id, household_id), + ) + if await parent_cursor.fetchone() is None: + raise ValueError("Основная категория не найдена") + try: + cursor = await db.execute( + """ + INSERT INTO budget_categories( + household_id, name, icon, parent_id, created_by, created_at + ) VALUES (?, ?, '📁', ?, ?, ?) + """, + (household_id, clean_name, parent_id, user_id, now.isoformat()), + ) + await db.commit() + except aiosqlite.IntegrityError as exc: + raise ValueError("Такая категория уже существует") from exc + category_id = int(cursor.lastrowid) + return next( + category for category in await self.budget_categories(user_id) + if category.id == category_id + ) + + async def update_transaction_budget_category( + self, transaction_id: int, user_id: int, category_id: int + ) -> bool: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + """ + UPDATE transactions + SET budget_category_id=? + WHERE id=? AND user_id=? AND kind='expense' + AND household_id=( + SELECT household_id FROM budget_categories WHERE id=? + ) + """, + (category_id, transaction_id, user_id, category_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def family_info(self, user_id: int) -> FamilyInfo | None: + async with aiosqlite.connect(self.path) as db: + return await self._family_info_in_connection(db, user_id) + + async def family_recipients(self, user_id: int) -> list[aiosqlite.Row]: + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT u.user_id, u.chat_id, u.full_name + FROM household_members mine + JOIN household_members other ON other.household_id=mine.household_id + JOIN users u ON u.user_id=other.user_id + WHERE mine.user_id=? AND other.user_id<>? + """, + (user_id, user_id), + ) + return await cursor.fetchall() + + async def family_notification_chats(self, user_id: int) -> list[int]: + recipients = [int(row["chat_id"]) for row in await self.family_recipients(user_id)] + info = await self.family_info(user_id) + if info and info.notification_chat_id is not None: + recipients.append(info.notification_chat_id) + return list(dict.fromkeys(recipients)) + + async def _family_info_in_connection( + self, db: aiosqlite.Connection, user_id: int + ) -> FamilyInfo | None: + cursor = await db.execute( + """ + SELECT h.id, h.invite_code, h.created_by, + h.notification_chat_id, h.notification_chat_title + FROM households h + JOIN household_members hm ON hm.household_id=h.id + WHERE hm.user_id=? + """, + (user_id,), + ) + row = await cursor.fetchone() + if row is None: + return None + members_cursor = await db.execute( + """ + SELECT u.full_name + FROM household_members hm JOIN users u ON u.user_id=hm.user_id + WHERE hm.household_id=? ORDER BY hm.joined_at + """, + (row[0],), + ) + members = tuple(str(member[0]) for member in await members_cursor.fetchall()) + return FamilyInfo( + household_id=int(row[0]), + invite_code=str(row[1]), + members=members, + owner_user_id=int(row[2]), + notification_chat_id=int(row[3]) if row[3] is not None else None, + notification_chat_title=str(row[4]) if row[4] is not None else None, + ) + + async def family_report_target(self, user_id: int) -> tuple[int, int] | None: + info = await self.family_info(user_id) + if info is None or info.notification_chat_id is None: + return None + return info.household_id, info.notification_chat_id + + async def family_report_was_sent(self, household_id: int, local_date: str) -> bool: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + "SELECT 1 FROM family_daily_reports WHERE household_id=? AND local_date=?", + (household_id, local_date), + ) + return await cursor.fetchone() is not None + + async def mark_family_report_sent( + self, household_id: int, local_date: str, sent_at: datetime + ) -> None: + async with aiosqlite.connect(self.path) as db: + await db.execute( + """ + INSERT OR IGNORE INTO family_daily_reports(household_id, local_date, sent_at) + VALUES (?, ?, ?) + """, + (household_id, local_date, sent_at.isoformat()), + ) + await db.commit() + + async def users_for_reports(self) -> list[aiosqlite.Row]: + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT user_id, chat_id, timezone, report_time FROM users" + ) + return await cursor.fetchall() + + async def report_was_sent(self, user_id: int, local_date: str) -> bool: + async with aiosqlite.connect(self.path) as db: + cursor = await db.execute( + "SELECT 1 FROM daily_reports WHERE user_id=? AND local_date=?", + (user_id, local_date), + ) + return await cursor.fetchone() is not None + + async def mark_report_sent(self, user_id: int, local_date: str, sent_at: datetime) -> None: + async with aiosqlite.connect(self.path) as db: + await db.execute( + "INSERT OR IGNORE INTO daily_reports(user_id, local_date, sent_at) VALUES (?, ?, ?)", + (user_id, local_date, sent_at.isoformat()), + ) + await db.commit() + + @staticmethod + def _row_to_transaction(row: aiosqlite.Row) -> Transaction: + return Transaction( + id=int(row["id"]), + user_id=int(row["user_id"]), + amount_kopecks=int(row["amount_kopecks"]), + description=str(row["description"]), + kind=TransactionKind(row["kind"]), + category=Category(row["category"]), + occurred_at=datetime.fromisoformat(row["occurred_at"]), + author_name=str(row["author_name"]) if "author_name" in row.keys() else None, + budget_category_id=( + int(row["budget_category_id"]) + if "budget_category_id" in row.keys() and row["budget_category_id"] is not None + else None + ), + budget_category_name=( + str(row["budget_category_name"]) + if "budget_category_name" in row.keys() and row["budget_category_name"] is not None + else None + ), + budget_category_icon=( + str(row["budget_category_icon"]) + if "budget_category_icon" in row.keys() and row["budget_category_icon"] is not None + else None + ), + parent_category_name=( + str(row["parent_category_name"]) + if "parent_category_name" in row.keys() and row["parent_category_name"] is not None + else None + ), + parent_category_icon=( + str(row["parent_category_icon"]) + if "parent_category_icon" in row.keys() and row["parent_category_icon"] is not None + else None + ), + ) + + @staticmethod + async def _seed_default_categories( + db: aiosqlite.Connection, household_id: int + ) -> None: + created_at = datetime.now().astimezone().isoformat() + for category in Category: + icon, name = CATEGORY_META[category] + await db.execute( + """ + INSERT OR IGNORE INTO budget_categories( + household_id, name, icon, parent_id, system_key, created_at + ) VALUES (?, ?, ?, NULL, ?, ?) + """, + (household_id, name, icon, category.value, created_at), + ) + parent_cursor = await db.execute( + "SELECT id FROM budget_categories WHERE household_id=? AND system_key=?", + (household_id, category.value), + ) + parent_id = int((await parent_cursor.fetchone())[0]) + for system_key, sub_icon, sub_name, _keywords in DEFAULT_SUBCATEGORIES.get( + category, () + ): + await db.execute( + """ + INSERT OR IGNORE INTO budget_categories( + household_id, name, icon, parent_id, system_key, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (household_id, sub_name, sub_icon, parent_id, system_key, created_at), + ) + + @staticmethod + def _scope_clause(alias: str) -> str: + return f"""( + ({alias}.household_id IS NOT NULL AND {alias}.household_id=( + SELECT household_id FROM household_members WHERE user_id=? + )) + OR ({alias}.household_id IS NULL AND {alias}.user_id=?) + )""" diff --git a/app/domain.py b/app/domain.py new file mode 100644 index 0000000..dba44e6 --- /dev/null +++ b/app/domain.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class TransactionKind(str, Enum): + EXPENSE = "expense" + INCOME = "income" + + +class Category(str, Enum): + PRODUCTS = "products" + ENTERTAINMENT = "entertainment" + CAR = "car" + CREDITS = "credits" + RENOVATION = "renovation" + OTHER = "other" + INCOME = "income" + + +@dataclass(frozen=True, slots=True) +class ParsedTransaction: + amount_kopecks: int + description: str + kind: TransactionKind + + +@dataclass(frozen=True, slots=True) +class Transaction: + id: int + user_id: int + amount_kopecks: int + description: str + kind: TransactionKind + category: Category + occurred_at: datetime + author_name: str | None = None + budget_category_id: int | None = None + budget_category_name: str | None = None + budget_category_icon: str | None = None + parent_category_name: str | None = None + parent_category_icon: str | None = None + + +@dataclass(frozen=True, slots=True) +class BudgetCategory: + id: int + name: str + icon: str + parent_id: int | None + parent_name: str | None = None + parent_icon: str | None = None + system_key: str | None = None + + @property + def display_name(self) -> str: + if self.parent_name: + return f"{self.parent_icon or '📁'} {self.parent_name} → {self.icon} {self.name}" + return f"{self.icon} {self.name}" + + +@dataclass(frozen=True, slots=True) +class FamilyInfo: + household_id: int + invite_code: str + members: tuple[str, ...] + owner_user_id: int + notification_chat_id: int | None = None + notification_chat_title: str | None = None + + +@dataclass(frozen=True, slots=True) +class FamilyJoinRequest: + candidate_user_id: int + candidate_name: str + owner_chat_id: int + + +@dataclass(frozen=True, slots=True) +class Totals: + income: int = 0 + expense: int = 0 + + @property + def balance(self) -> int: + return self.income - self.expense diff --git a/app/handlers.py b/app/handlers.py new file mode 100644 index 0000000..d4e463a --- /dev/null +++ b/app/handlers.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +from datetime import datetime +from html import escape + +from aiogram import Bot, F, Router +from aiogram.exceptions import TelegramForbiddenError +from aiogram.enums import ChatMemberStatus, ChatType +from aiogram.filters import Command, CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, Message + +from app.categories import Category, category_label, classify, suggest_subcategory +from app.config import Settings +from app.database import Database, FamilyJoinError +from app.domain import TransactionKind +from app.keyboards import ( + ALL_TOTAL, + ANALYTICS, + CATEGORIES, + DEBT, + FAMILY, + HELP, + MONTH_TOTAL, + RECENT, + analytics_period_keyboard, + category_management_keyboard, + category_tree_keyboard, + category_keyboard, + debt_keyboard, + family_setup_keyboard, + family_approval_keyboard, + main_keyboard, + parent_category_keyboard, + transaction_actions, +) +from app.messages import ( + HELP_TEXT, + MONTHS, + WELCOME, + analytics_text, + debt_short_text, + debt_status_text, + family_status_text, + family_transaction_notice, + recent_text, + summary_text, + transaction_confirmation, + transaction_category_text, +) +from app.parser import ParseError, parse_money_amount, parse_transaction +from app.periods import month_bounds + + +class FamilySetup(StatesGroup): + waiting_for_code = State() + + +class DebtSetup(StatesGroup): + waiting_for_amount = State() + + +class CategorySetup(StatesGroup): + waiting_for_name = State() + + +def create_router(database: Database, settings: Settings) -> Router: + root_router = Router(name=__name__) + group_router = Router(name=f"{__name__}.groups") + router = Router(name=f"{__name__}.private") + router.message.filter(F.chat.type == ChatType.PRIVATE) + + @group_router.message( + Command("family_chat"), + F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), + ) + async def bind_family_chat(message: Message, bot: Bot) -> None: + if message.from_user is None: + return + member = await bot.get_chat_member(message.chat.id, message.from_user.id) + if member.status not in {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR}: + await message.reply("Привязать семейный чат может только его администратор.") + return + try: + info = await database.bind_family_chat( + message.from_user.id, + message.chat.id, + message.chat.title or "Семейный чат", + ) + except FamilyJoinError as exc: + await message.reply(f"Не удалось привязать чат: {escape(str(exc))}") + return + await message.reply( + "✅ Чат подключён к семейному бюджету.\n\n" + "Сюда будут приходить новые записи обоих супругов и один общий вечерний отчёт." + ) + + async def register(message: Message) -> None: + if message.from_user is None: + return + await database.upsert_user( + user_id=message.from_user.id, + chat_id=message.chat.id, + username=message.from_user.username, + full_name=message.from_user.full_name, + timezone=settings.timezone.key, + report_time=settings.daily_report_time.strftime("%H:%M"), + now=datetime.now(settings.timezone), + ) + + @router.message(CommandStart()) + async def start(message: Message) -> None: + await register(message) + await message.answer(WELCOME, reply_markup=main_keyboard()) + + @router.message(Command("help")) + @router.message(F.text == HELP) + async def help_message(message: Message) -> None: + await register(message) + await message.answer(HELP_TEXT, reply_markup=main_keyboard()) + + @router.message(Command("month")) + @router.message(F.text == MONTH_TOTAL) + async def month_total(message: Message) -> None: + await register(message) + now = datetime.now(settings.timezone) + start_at, end_at = month_bounds(now) + totals = await database.totals(message.from_user.id, start_at, end_at) + await message.answer(summary_text(f"Итог за {MONTHS[now.month]}", totals)) + + @router.message(Command("total")) + @router.message(F.text == ALL_TOTAL) + async def all_total(message: Message) -> None: + await register(message) + totals = await database.totals(message.from_user.id) + await message.answer(summary_text("Итог за всё время", totals)) + + @router.message(Command("analytics")) + @router.message(F.text == ANALYTICS) + async def analytics(message: Message) -> None: + await register(message) + now = datetime.now(settings.timezone) + start_at, end_at = month_bounds(now) + categories = await database.category_totals(message.from_user.id, start_at, end_at) + await message.answer( + analytics_text(f"за {MONTHS[now.month]}", categories, sum(categories.values())), + reply_markup=analytics_period_keyboard("month"), + ) + + @router.callback_query(F.data.in_({"analytics:month", "analytics:all"})) + async def switch_analytics_period(callback: CallbackQuery) -> None: + period = (callback.data or "").split(":", maxsplit=1)[1] + now = datetime.now(settings.timezone) + if period == "month": + start_at, end_at = month_bounds(now) + categories = await database.category_totals( + callback.from_user.id, start_at, end_at + ) + period_name = f"за {MONTHS[now.month]}" + else: + categories = await database.category_totals(callback.from_user.id) + period_name = "за всё время" + if callback.message: + await callback.message.edit_text( + analytics_text(period_name, categories, sum(categories.values())), + reply_markup=analytics_period_keyboard(period), + ) + await callback.answer() + + @router.message(Command("recent")) + @router.message(F.text == RECENT) + async def recent(message: Message) -> None: + await register(message) + transactions = await database.recent_transactions(message.from_user.id) + await message.answer(recent_text(transactions)) + + @router.message(Command("family")) + @router.message(F.text == FAMILY) + async def family_menu(message: Message) -> None: + await register(message) + info = await database.family_info(message.from_user.id) + await message.answer( + family_status_text(info), + reply_markup=family_setup_keyboard() if info is None else None, + ) + + @router.message(Command("debt")) + @router.message(F.text == DEBT) + async def debt_menu(message: Message) -> None: + await register(message) + info = await database.family_info(message.from_user.id) + if info is None: + await message.answer( + "Сначала создайте семейный бюджет через кнопку «👨‍👩‍👧 Семья»." + ) + return + debt = await database.family_debt(message.from_user.id) + can_edit = info.owner_user_id == message.from_user.id + await message.answer( + debt_status_text(debt, can_edit), + reply_markup=debt_keyboard(can_edit), + ) + + @router.message(Command("categories")) + @router.message(F.text == CATEGORIES) + async def categories_menu(message: Message) -> None: + await register(message) + categories = await database.budget_categories(message.from_user.id) + if not categories: + await message.answer( + "Настраиваемые категории доступны после подключения семейного бюджета." + ) + return + lines = ["🗂 Категории семейного бюджета", ""] + for category in categories: + prefix = " ↳ " if category.parent_id is not None else "" + lines.append(f"{prefix}{escape(category.icon)} {escape(category.name)}") + await message.answer( + "\n".join(lines), reply_markup=category_management_keyboard() + ) + + @router.callback_query(F.data == "catmanage:add-main") + async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None: + await state.set_state(CategorySetup.waiting_for_name) + await state.set_data({"parent_id": None}) + if callback.message: + await callback.message.answer( + "Введите название новой категории, например Здоровье.\n\n" + "Для отмены отправьте /cancel" + ) + await callback.answer() + + @router.callback_query(F.data == "catmanage:add-sub") + async def choose_parent_category(callback: CallbackQuery) -> None: + categories = await database.budget_categories(callback.from_user.id) + if callback.message: + await callback.message.answer( + "К какой основной категории добавить подкатегорию?", + reply_markup=parent_category_keyboard(categories), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("catmanage:parent:")) + async def ask_subcategory_name(callback: CallbackQuery, state: FSMContext) -> None: + try: + parent_id = int((callback.data or "").rsplit(":", maxsplit=1)[1]) + except (ValueError, IndexError): + await callback.answer("Категория не найдена", show_alert=True) + return + await state.set_state(CategorySetup.waiting_for_name) + await state.set_data({"parent_id": parent_id}) + if callback.message: + await callback.message.answer( + "Введите название подкатегории, например Врачи.\n\n" + "Для отмены отправьте /cancel" + ) + await callback.answer() + + @router.callback_query(F.data == "catmanage:cancel") + async def cancel_category_callback(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + if callback.message: + await callback.message.edit_reply_markup(reply_markup=None) + await callback.answer("Отменено") + + @router.message(CategorySetup.waiting_for_name, Command("cancel")) + async def cancel_new_category(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer("Добавление категории отменено.", reply_markup=main_keyboard()) + + @router.message(CategorySetup.waiting_for_name, F.text) + async def save_new_category(message: Message, state: FSMContext) -> None: + data = await state.get_data() + try: + category = await database.create_budget_category( + user_id=message.from_user.id, + name=message.text or "", + parent_id=data.get("parent_id"), + now=datetime.now(settings.timezone), + ) + except (ValueError, FamilyJoinError) as exc: + await message.answer(f"Не получилось добавить категорию: {escape(str(exc))}") + return + await state.clear() + await message.answer( + f"✅ Добавлено: {escape(category.display_name)}", + reply_markup=main_keyboard(), + ) + + @router.callback_query(F.data == "debt:set") + async def ask_debt_amount(callback: CallbackQuery, state: FSMContext) -> None: + info = await database.family_info(callback.from_user.id) + if info is None or info.owner_user_id != callback.from_user.id: + await callback.answer("Изменить долг может только создатель бюджета", show_alert=True) + return + await state.set_state(DebtSetup.waiting_for_amount) + if callback.message: + await callback.message.answer( + "Введите текущую сумму общего долга.\n" + "Например: 530713 или 530 713 ₽\n\n" + "Для отмены отправьте /cancel" + ) + await callback.answer() + + @router.message(DebtSetup.waiting_for_amount, Command("cancel")) + async def cancel_debt(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer("Изменение долга отменено.", reply_markup=main_keyboard()) + + @router.message(DebtSetup.waiting_for_amount, F.text) + async def save_debt(message: Message, state: FSMContext, bot: Bot) -> None: + try: + amount = parse_money_amount(message.text or "") + await database.set_family_debt( + message.from_user.id, amount, datetime.now(settings.timezone) + ) + except (ParseError, FamilyJoinError) as exc: + await message.answer(f"Не получилось сохранить сумму: {escape(str(exc))}") + return + await state.clear() + await message.answer( + debt_status_text(amount, True), + reply_markup=debt_keyboard(True), + ) + for chat_id in await database.family_notification_chats(message.from_user.id): + try: + await bot.send_message( + chat_id, + f"💳 {escape(message.from_user.full_name)} обновил(а) общий долг.\n\n" + f"{debt_short_text(amount)}", + ) + except TelegramForbiddenError: + pass + + @router.callback_query(F.data == "family:create") + async def create_family(callback: CallbackQuery) -> None: + info = await database.create_family( + callback.from_user.id, datetime.now(settings.timezone) + ) + if callback.message: + await callback.message.answer(family_status_text(info)) + await callback.message.edit_reply_markup(reply_markup=None) + await callback.answer("Общий бюджет создан") + + @router.callback_query(F.data == "family:join") + async def ask_family_code(callback: CallbackQuery, state: FSMContext) -> None: + await state.set_state(FamilySetup.waiting_for_code) + if callback.message: + await callback.message.answer( + "Введите код приглашения, который показан у супруга.\n" + "Например: FIN-ABC123\n\n" + "Для отмены отправьте /cancel" + ) + await callback.answer() + + @router.message(FamilySetup.waiting_for_code, Command("cancel")) + async def cancel_family_join(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer("Подключение отменено.", reply_markup=main_keyboard()) + + @router.message(FamilySetup.waiting_for_code, F.text) + async def join_family(message: Message, state: FSMContext, bot: Bot) -> None: + await register(message) + try: + request = await database.request_family_join( + message.from_user.id, + message.text or "", + datetime.now(settings.timezone), + ) + except FamilyJoinError as exc: + await message.answer( + f"Не получилось подключиться: {exc}\n\n" + "Проверьте код или отправьте /cancel." + ) + return + await state.clear() + await message.answer( + "🔐 Запрос отправлен владельцу бюджета.\n\n" + "Доступ появится только после его подтверждения.", + reply_markup=main_keyboard(), + ) + try: + await bot.send_message( + request.owner_chat_id, + f"🔐 {escape(request.candidate_name)} просит доступ к семейному бюджету.\n\n" + "Подтвердите только знакомого вам человека.", + reply_markup=family_approval_keyboard(request.candidate_user_id), + ) + except TelegramForbiddenError: + await message.answer("Владелец бюджета заблокировал бота, запрос нельзя подтвердить.") + + @router.callback_query(F.data.startswith("family:approve:")) + @router.callback_query(F.data.startswith("family:reject:")) + async def resolve_family_request(callback: CallbackQuery, bot: Bot) -> None: + parts = (callback.data or "").split(":") + try: + approve = parts[1] == "approve" + candidate_user_id = int(parts[2]) + approved, candidate_chat_id, info = await database.resolve_family_join( + owner_user_id=callback.from_user.id, + candidate_user_id=candidate_user_id, + approve=approve, + now=datetime.now(settings.timezone), + ) + except (IndexError, ValueError, FamilyJoinError) as exc: + await callback.answer(str(exc), show_alert=True) + return + if callback.message: + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer( + "✅ Доступ подтверждён." if approved else "🚫 Запрос отклонён." + ) + if approved and info is not None: + await bot.send_message( + candidate_chat_id, + family_status_text(info), + reply_markup=main_keyboard(), + ) + else: + await bot.send_message( + candidate_chat_id, + "🚫 Владелец семейного бюджета отклонил запрос на подключение.", + ) + await callback.answer() + + @router.callback_query(F.data.startswith("choose:")) + async def choose_category(callback: CallbackQuery) -> None: + transaction_id = _callback_id(callback.data) + transaction = await database.get_transaction(transaction_id, callback.from_user.id) + if transaction is None or transaction.kind is TransactionKind.INCOME: + await callback.answer("Запись не найдена", show_alert=True) + return + categories = await database.budget_categories(callback.from_user.id) + if callback.message: + await callback.message.edit_reply_markup( + reply_markup=( + category_tree_keyboard(transaction_id, categories) + if categories + else category_keyboard(transaction_id) + ) + ) + await callback.answer("Выберите категорию") + + @router.callback_query(F.data.startswith("bcat:")) + async def set_budget_category(callback: CallbackQuery, bot: Bot) -> None: + try: + _, transaction_id_raw, category_id_raw = (callback.data or "").split(":") + transaction_id = int(transaction_id_raw) + category_id = int(category_id_raw) + except (ValueError, TypeError): + await callback.answer("Некорректная категория", show_alert=True) + return + changed = await database.update_transaction_budget_category( + transaction_id, callback.from_user.id, category_id + ) + transaction = await database.get_transaction(transaction_id, callback.from_user.id) + if not changed or transaction is None: + await callback.answer("Запись или категория не найдена", show_alert=True) + return + debt = await database.family_debt(callback.from_user.id) + if callback.message: + await callback.message.edit_text( + transaction_confirmation(transaction, debt), + reply_markup=transaction_actions(transaction.id, is_expense=True), + ) + for chat_id in await database.family_notification_chats(callback.from_user.id): + try: + await bot.send_message( + chat_id, + f"🗂 {escape(callback.from_user.full_name)} изменил(а) категорию записи.\n\n" + f"{escape(transaction.description)} → {transaction_category_text(transaction)}", + ) + except TelegramForbiddenError: + pass + await callback.answer("Категория изменена") + + @router.callback_query(F.data.startswith("category:")) + async def set_category(callback: CallbackQuery) -> None: + try: + _, transaction_id_raw, category_raw = (callback.data or "").split(":", maxsplit=2) + transaction_id = int(transaction_id_raw) + category = Category(category_raw) + if category is Category.INCOME: + raise ValueError + except (ValueError, TypeError): + await callback.answer("Некорректная категория", show_alert=True) + return + changed = await database.update_category(transaction_id, callback.from_user.id, category) + transaction = await database.get_transaction(transaction_id, callback.from_user.id) + if not changed or transaction is None: + await callback.answer("Запись не найдена", show_alert=True) + return + if callback.message: + debt = await database.family_debt(callback.from_user.id) + await callback.message.edit_text( + transaction_confirmation(transaction, debt), + reply_markup=transaction_actions(transaction.id, is_expense=True), + ) + await callback.answer(f"Категория: {category_label(category)}") + + @router.callback_query(F.data.startswith("cancel:")) + async def cancel_category(callback: CallbackQuery) -> None: + transaction_id = _callback_id(callback.data) + transaction = await database.get_transaction(transaction_id, callback.from_user.id) + if transaction is None: + await callback.answer("Запись не найдена", show_alert=True) + return + if callback.message: + await callback.message.edit_reply_markup( + reply_markup=transaction_actions( + transaction.id, is_expense=transaction.kind is TransactionKind.EXPENSE + ) + ) + await callback.answer() + + @router.callback_query(F.data.startswith("delete:")) + async def delete_transaction(callback: CallbackQuery) -> None: + transaction_id = _callback_id(callback.data) + deleted = await database.delete_transaction(transaction_id, callback.from_user.id) + if not deleted: + await callback.answer("Запись уже удалена", show_alert=True) + return + if callback.message: + debt = await database.family_debt(callback.from_user.id) + debt_line = f"\n\n{debt_short_text(debt)}" if debt is not None else "" + await callback.message.edit_text(f"🗑 Запись удалена{debt_line}") + await callback.answer("Удалено") + + @router.message(F.text) + async def add_transaction(message: Message, bot: Bot) -> None: + await register(message) + try: + parsed = parse_transaction(message.text or "") + except ParseError: + await message.answer( + "Не получилось распознать запись 🤔\n\n" + "Напишите сумму и описание, например: 467 ярче\n" + "Доход: + 50000 зарплата" + ) + return + + category = ( + Category.INCOME + if parsed.kind is TransactionKind.INCOME + else classify(parsed.description) + ) + subcategory_key = ( + None + if parsed.kind is TransactionKind.INCOME + else suggest_subcategory(category, parsed.description) + ) + budget_category_id = await database.resolve_budget_category( + message.from_user.id, category, subcategory_key + ) + transaction = await database.add_transaction( + user_id=message.from_user.id, + amount_kopecks=parsed.amount_kopecks, + description=parsed.description, + kind=parsed.kind, + category=category, + budget_category_id=budget_category_id, + occurred_at=datetime.now(settings.timezone), + ) + debt = await database.family_debt(message.from_user.id) + await message.answer( + transaction_confirmation(transaction, debt), + reply_markup=transaction_actions( + transaction.id, is_expense=transaction.kind is TransactionKind.EXPENSE + ), + ) + for chat_id in await database.family_notification_chats(message.from_user.id): + try: + await bot.send_message( + chat_id, family_transaction_notice(transaction, debt) + ) + except TelegramForbiddenError: + pass + + @router.message() + async def unsupported(message: Message) -> None: + await message.answer("Пока я понимаю только текстовые записи — например, 3000 бензин.") + + root_router.include_routers(group_router, router) + return root_router + + +def _callback_id(data: str | None) -> int: + try: + return int((data or "").split(":", maxsplit=1)[1]) + except (ValueError, IndexError) as exc: + raise ValueError("Некорректный идентификатор операции") from exc diff --git a/app/keyboards.py b/app/keyboards.py new file mode 100644 index 0000000..584eb3b --- /dev/null +++ b/app/keyboards.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup + +from app.categories import EXPENSE_CATEGORIES, category_label +from app.domain import BudgetCategory + + +MONTH_TOTAL = "📅 Итог за месяц" +ALL_TOTAL = "💰 Итог общий" +ANALYTICS = "📊 Аналитика" +RECENT = "🧾 Последние записи" +FAMILY = "👨‍👩‍👧 Семья" +DEBT = "💳 Общий долг" +CATEGORIES = "🗂 Категории" +HELP = "❓ Помощь" + + +def main_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text=MONTH_TOTAL), KeyboardButton(text=ALL_TOTAL)], + [KeyboardButton(text=ANALYTICS), KeyboardButton(text=RECENT)], + [KeyboardButton(text=FAMILY), KeyboardButton(text=DEBT)], + [KeyboardButton(text=CATEGORIES), KeyboardButton(text=HELP)], + ], + resize_keyboard=True, + input_field_placeholder="Например: 467 ярче", + is_persistent=True, + ) + + +def family_setup_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Создать общий бюджет", callback_data="family:create")], + [InlineKeyboardButton(text="Ввести код супруга", callback_data="family:join")], + ] + ) + + +def family_approval_keyboard(candidate_user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"family:approve:{candidate_user_id}", + ), + InlineKeyboardButton( + text="🚫 Отклонить", + callback_data=f"family:reject:{candidate_user_id}", + ), + ] + ] + ) + + +def debt_keyboard(can_edit: bool) -> InlineKeyboardMarkup | None: + if not can_edit: + return None + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="✏️ Указать текущий долг", callback_data="debt:set")] + ] + ) + + +def category_tree_keyboard( + transaction_id: int, categories: list[BudgetCategory] +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + for category in categories: + if category.parent_name: + text = f"↳ {category.icon} {category.name}" + else: + text = f"{category.icon} {category.name}" + rows.append( + [ + InlineKeyboardButton( + text=text, + callback_data=f"bcat:{transaction_id}:{category.id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="Отмена", callback_data=f"cancel:{transaction_id}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def category_management_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="➕ Добавить категорию", callback_data="catmanage:add-main")], + [InlineKeyboardButton(text="↳ Добавить подкатегорию", callback_data="catmanage:add-sub")], + ] + ) + + +def parent_category_keyboard(categories: list[BudgetCategory]) -> InlineKeyboardMarkup: + parents = [category for category in categories if category.parent_id is None] + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"{category.icon} {category.name}", + callback_data=f"catmanage:parent:{category.id}", + ) + ] + for category in parents + ] + + [[InlineKeyboardButton(text="Отмена", callback_data="catmanage:cancel")]] + ) + + +def transaction_actions(transaction_id: int, *, is_expense: bool) -> InlineKeyboardMarkup: + buttons: list[InlineKeyboardButton] = [] + if is_expense: + buttons.append( + InlineKeyboardButton(text="Изменить категорию", callback_data=f"choose:{transaction_id}") + ) + buttons.append(InlineKeyboardButton(text="Удалить", callback_data=f"delete:{transaction_id}")) + return InlineKeyboardMarkup(inline_keyboard=[buttons]) + + +def category_keyboard(transaction_id: int) -> InlineKeyboardMarkup: + rows = [] + categories = list(EXPENSE_CATEGORIES) + for index in range(0, len(categories), 2): + rows.append( + [ + InlineKeyboardButton( + text=category_label(category), + callback_data=f"category:{transaction_id}:{category.value}", + ) + for category in categories[index : index + 2] + ] + ) + rows.append([InlineKeyboardButton(text="Отмена", callback_data=f"cancel:{transaction_id}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def analytics_period_keyboard(active: str = "month") -> InlineKeyboardMarkup: + month_label = "✓ Этот месяц" if active == "month" else "Этот месяц" + all_label = "✓ Всё время" if active == "all" else "Всё время" + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=month_label, callback_data="analytics:month"), + InlineKeyboardButton(text=all_label, callback_data="analytics:all"), + ] + ] + ) diff --git a/app/messages.py b/app/messages.py new file mode 100644 index 0000000..1bf7ec3 --- /dev/null +++ b/app/messages.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from html import escape + +from app.categories import category_label +from app.domain import FamilyInfo, Totals, Transaction, TransactionKind +from app.parser import format_money + + +MONTHS = ( + "", + "январь", + "февраль", + "март", + "апрель", + "май", + "июнь", + "июль", + "август", + "сентябрь", + "октябрь", + "ноябрь", + "декабрь", +) + + +WELCOME = """Привет! Я помогу держать деньги под контролем 👋 + +Просто присылай сумму и описание — я сам определю категорию: + +467 ярче +3000 бензин ++ 85000 зарплата + +Обычная запись — расход. Знак + или слово доход — поступление. + +Каждый вечер я пришлю короткий итог дня и месяца. А в разделе «Семья» можно подключить общий бюджет с супругом. Начнём?""" + + +HELP_TEXT = """Как записывать операции + +Расход: +1250 продукты +3 500 ремонт машины + +Доход: ++ 75000 зарплата +доход 5000 подработка + +Можно вводить копейки: 199,90 кофе. + +После записи категорию можно изменить, а ошибочную операцию — удалить. Кнопки внизу показывают итоги и аналитику. + +В семейном режиме кнопка «Общий долг» включает автоматический расчёт: расходы увеличивают долг, доходы уменьшают. + +Через «Категории» можно добавить свои категории и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое».""" + + +def transaction_confirmation(transaction: Transaction, debt: int | None = None) -> str: + is_income = transaction.kind is TransactionKind.INCOME + heading = "Доход записан" if is_income else "Расход записан" + icon = "🟢" if is_income else "🔴" + signed_amount = transaction.amount_kopecks if is_income else -transaction.amount_kopecks + debt_line = f"\n\n{debt_short_text(debt)}" if debt is not None else "" + return ( + f"{icon} {heading}\n\n" + f"{format_money(signed_amount, signed=True)} · {escape(transaction.description)}\n" + f"{transaction_category_text(transaction)}{debt_line}" + ) + + +def summary_text(title: str, totals: Totals) -> str: + balance_icon = "🟢" if totals.balance >= 0 else "🔴" + return ( + f"{escape(title)}\n\n" + f"↗️ Доходы {format_money(totals.income)}\n" + f"↘️ Расходы {format_money(totals.expense)}\n" + f"━━━━━━━━━━━━\n" + f"{balance_icon} Баланс {format_money(totals.balance, signed=True)}" + ) + + +def analytics_text(period_name: str, category_totals: dict, total: int) -> str: + title = f"📊 Расходы {escape(period_name)}" + if total == 0: + return f"{title}\n\nПока расходов нет. Самое время записать первый 👇" + + lines = [title, f"Всего: {format_money(total)}", ""] + for label, amount in category_totals.items(): + percent = round(amount / total * 100) + filled = max(1, round(percent / 10)) + bar = "▰" * filled + "▱" * (10 - filled) + lines.append(escape(label)) + lines.append(f"{bar} {percent}% · {format_money(amount)}") + lines.append("") + return "\n".join(lines).rstrip() + + +def recent_text(transactions: list[Transaction]) -> str: + if not transactions: + return "🧾 Последние записи\n\nЗдесь пока пусто." + lines = ["🧾 Последние записи", ""] + for transaction in transactions: + is_income = transaction.kind is TransactionKind.INCOME + amount = transaction.amount_kopecks if is_income else -transaction.amount_kopecks + author = f" · {escape(transaction.author_name)}" if transaction.author_name else "" + lines.append( + f"{transaction.occurred_at:%d.%m} {format_money(amount, signed=True)} " + f"{escape(transaction.description)}{author}" + ) + lines.append("") + lines.append("Удалить или исправить запись можно сразу после её добавления.") + return "\n".join(lines) + + +def family_status_text(info: FamilyInfo | None) -> str: + if info is None: + return ( + "👨‍👩‍👧 Семейный бюджет\n\n" + "Объедините два Telegram-аккаунта. После подключения вы оба будете видеть " + "общие итоги, аналитику и новые записи в реальном времени." + ) + members = "\n".join(f"• {escape(name)}" for name in info.members) + waiting = ( + "\n\nОтправьте код супругу. Подключение произойдёт только после вашего подтверждения." + if len(info.members) < 2 + else "" + ) + chat = ( + f"\n\n💬 Общий чат: {escape(info.notification_chat_title or 'Подключён')}" + if info.notification_chat_id is not None + else ( + "\n\n💬 Общий чат пока не подключён. Создатель бюджета должен добавить " + "бота в семейную группу, стать администратором группы и отправить там /family_chat." + ) + ) + return ( + "👨‍👩‍👧 Семейный бюджет подключён\n\n" + f"Участники:\n{members}\n\n" + f"Код приглашения: {escape(info.invite_code)}" + f"{waiting}{chat}" + ) + + +def family_transaction_notice(transaction: Transaction, debt: int | None = None) -> str: + is_income = transaction.kind is TransactionKind.INCOME + action = "добавил(а) доход" if is_income else "добавил(а) расход" + icon = "🟢" if is_income else "🔴" + amount = transaction.amount_kopecks if is_income else -transaction.amount_kopecks + author = escape(transaction.author_name or "Супруг") + debt_line = f"\n\n{debt_short_text(debt)}" if debt is not None else "" + return ( + f"👨‍👩‍👧 {author} {action}\n\n" + f"{icon} {format_money(amount, signed=True)} · {escape(transaction.description)}\n" + f"{transaction_category_text(transaction)}{debt_line}" + ) + + +def daily_report_text(day: Totals, month: Totals, debt: int | None = None) -> str: + balance_icon = "✨" if day.balance >= 0 else "📌" + debt_section = f"\n\n{debt_short_text(debt)}" if debt is not None else "" + return ( + "🌙 Итог дня\n\n" + f"Потрачено сегодня: {format_money(day.expense)}\n" + f"Получено сегодня: {format_money(day.income)}\n" + f"{balance_icon} Баланс дня: {format_money(day.balance, signed=True)}\n\n" + "С начала месяца\n" + f"Расходы: {format_money(month.expense)}\n" + f"Доходы: {format_money(month.income)}\n" + f"Баланс: {format_money(month.balance, signed=True)}" + f"{debt_section}" + ) + + +def debt_short_text(debt: int) -> str: + if debt > 0: + return f"💳 Общий долг: {format_money(debt)}" + if debt < 0: + return f"🌿 Семейный резерв: {format_money(abs(debt))}" + return "🎉 Общий долг: погашен" + + +def debt_status_text(debt: int | None, can_edit: bool) -> str: + if debt is None: + status = "Общий долг пока не указан." + else: + status = debt_short_text(debt) + edit_hint = ( + "\n\nУкажите текущую сумму долга. С этого момента каждый расход будет её увеличивать, " + "а каждый доход — уменьшать." + if can_edit + else "\n\nИзменить исходную сумму может только создатель семейного бюджета." + ) + return f"💳 Общий долг\n\n{status}{edit_hint}" + + +def transaction_category_text(transaction: Transaction) -> str: + if transaction.budget_category_name and transaction.parent_category_name: + return ( + f"{escape(transaction.parent_category_icon or '📁')} " + f"{escape(transaction.parent_category_name)} → " + f"{escape(transaction.budget_category_icon or '📁')} " + f"{escape(transaction.budget_category_name)}" + ) + if transaction.budget_category_name: + return ( + f"{escape(transaction.budget_category_icon or '📁')} " + f"{escape(transaction.budget_category_name)}" + ) + return category_label(transaction.category) diff --git a/app/parser.py b/app/parser.py new file mode 100644 index 0000000..af0a3af --- /dev/null +++ b/app/parser.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +from app.domain import ParsedTransaction, TransactionKind + + +class ParseError(ValueError): + pass + + +_TRANSACTION_RE = re.compile( + r"^\s*(?P[+-])?\s*" + r"(?:(?Pдоход|приход|расход|трата)\s+)?" + r"(?P\d[\d ]*(?:[,.]\d{1,2})?)\s*" + r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s+" + r"(?P\S.*)\s*$", + flags=re.IGNORECASE, +) + + +def parse_transaction(text: str) -> ParsedTransaction: + match = _TRANSACTION_RE.match(text) + if not match: + raise ParseError("Не удалось распознать сумму и описание") + + raw_amount = match.group("amount").replace(" ", "").replace(",", ".") + try: + amount = Decimal(raw_amount) + except InvalidOperation as exc: + raise ParseError("Некорректная сумма") from exc + if amount <= 0: + raise ParseError("Сумма должна быть больше нуля") + + amount_kopecks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + description = " ".join(match.group("description").split()) + kind_word = (match.group("kind") or "").casefold() + sign = match.group("sign") + kind = ( + TransactionKind.INCOME + if sign == "+" or kind_word in {"доход", "приход"} + else TransactionKind.EXPENSE + ) + if sign == "-" and kind is TransactionKind.INCOME: + raise ParseError("Доход не может быть отрицательным") + + return ParsedTransaction( + amount_kopecks=amount_kopecks, + description=description, + kind=kind, + ) + + +def format_money(kopecks: int, *, signed: bool = False) -> str: + sign = "" + if signed: + sign = "+" if kopecks > 0 else ("−" if kopecks < 0 else "") + absolute = abs(kopecks) + rubles, cents = divmod(absolute, 100) + grouped = f"{rubles:,}".replace(",", " ") + value = f"{grouped},{cents:02d}" if cents else grouped + return f"{sign}{value} ₽" + + +_MONEY_ONLY_RE = re.compile( + r"^\s*(?P\d[\d ]*(?:[,.]\d{1,2})?)\s*" + r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s*$", + flags=re.IGNORECASE, +) + + +def parse_money_amount(text: str) -> int: + match = _MONEY_ONLY_RE.match(text) + if not match: + raise ParseError("Некорректная сумма") + raw_amount = match.group("amount").replace(" ", "").replace(",", ".") + try: + amount = Decimal(raw_amount) + except InvalidOperation as exc: + raise ParseError("Некорректная сумма") from exc + if amount < 0: + raise ParseError("Сумма не может быть отрицательной") + return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) diff --git a/app/periods.py b/app/periods.py new file mode 100644 index 0000000..5e09176 --- /dev/null +++ b/app/periods.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + + +def day_bounds(now: datetime) -> tuple[datetime, datetime]: + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return start, start + timedelta(days=1) + + +def month_bounds(now: datetime) -> tuple[datetime, datetime]: + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if start.month == 12: + end = start.replace(year=start.year + 1, month=1) + else: + end = start.replace(month=start.month + 1) + return start, end + diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..dd43bd3 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from datetime import datetime, time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from aiogram import Bot +from aiogram.exceptions import TelegramForbiddenError + +from app.database import Database +from app.keyboards import main_keyboard +from app.messages import daily_report_text +from app.periods import day_bounds, month_bounds + + +logger = logging.getLogger(__name__) + + +async def daily_report_loop(bot: Bot, database: Database) -> None: + """Check report schedules periodically; the database makes delivery idempotent.""" + while True: + try: + await _send_due_reports(bot, database) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Ошибка цикла ежедневных отчётов") + await asyncio.sleep(30) + + +async def _send_due_reports(bot: Bot, database: Database) -> None: + users = await database.users_for_reports() + for user in users: + try: + timezone = ZoneInfo(user["timezone"]) + report_at = time.fromisoformat(user["report_time"]) + except (ZoneInfoNotFoundError, ValueError): + logger.error("Некорректные настройки времени у пользователя %s", user["user_id"]) + continue + + now = datetime.now(timezone) + if now.time().replace(tzinfo=None) < report_at: + continue + local_date = now.date().isoformat() + day_start, day_end = day_bounds(now) + month_start, month_end = month_bounds(now) + day = await database.totals(user["user_id"], day_start, day_end) + month = await database.totals(user["user_id"], month_start, month_end) + debt = await database.family_debt(user["user_id"]) + report_text = daily_report_text(day, month, debt) + if not await database.report_was_sent(user["user_id"], local_date): + try: + await bot.send_message( + chat_id=user["chat_id"], + text=report_text, + reply_markup=main_keyboard(), + ) + except TelegramForbiddenError: + logger.info("Пользователь %s заблокировал бота", user["user_id"]) + await database.mark_report_sent(user["user_id"], local_date, now) + + family_target = await database.family_report_target(user["user_id"]) + if family_target is None: + continue + household_id, family_chat_id = family_target + if await database.family_report_was_sent(household_id, local_date): + continue + try: + await bot.send_message(chat_id=family_chat_id, text=report_text) + except TelegramForbiddenError: + logger.info("Бот не может писать в семейный чат %s", family_chat_id) + await database.mark_family_report_sent(household_id, local_date, now) + + +async def stop_task(task: asyncio.Task) -> None: + task.cancel() + with suppress(asyncio.CancelledError): + await task diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..daa307a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + finance-bot: + build: . + container_name: finance-telegram-bot + restart: unless-stopped + env_file: .env + volumes: + - finance-data:/app/data + +volumes: + finance-data: + diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..f62d3d7 --- /dev/null +++ b/install.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +PUBLIC_URL="${FINANCE_BOT_PUBLIC_URL:-https://disk.yandex.ru/d/hhbDTO08CB030Q}" +DEFAULT_INSTALL_DIR="/opt/finance-bot" +YANDEX_API="https://cloud-api.yandex.net/v1/disk/public/resources/download" +TMP_DIR="" + +if [[ -t 1 ]]; then + BOLD='\033[1m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + RED='\033[0;31m' + RESET='\033[0m' +else + BOLD='' + GREEN='' + YELLOW='' + RED='' + RESET='' +fi + +info() { printf '%b\n' "${GREEN}✓${RESET} $*"; } +warn() { printf '%b\n' "${YELLOW}!${RESET} $*"; } +fail() { printf '%b\n' "${RED}Ошибка:${RESET} $*" >&2; exit 1; } + +cleanup() { + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf -- "$TMP_DIR" + fi +} +trap cleanup EXIT + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fail "запустите установщик с правами администратора: sudo bash install.sh" + fi +} + +install_base_tools() { + local missing=() + local command_name + for command_name in curl unzip python3; do + command -v "$command_name" >/dev/null 2>&1 || missing+=("$command_name") + done + [[ -e /usr/share/zoneinfo/UTC ]] || missing+=("tzdata") + if (( ${#missing[@]} == 0 )); then + return + fi + + command -v apt-get >/dev/null 2>&1 \ + || fail "установите curl, unzip и python3, затем запустите скрипт повторно" + warn "Устанавливаю системные утилиты: ${missing[*]}" + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl unzip python3 tzdata +} + +install_docker() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + info "Docker и Docker Compose уже установлены" + return + fi + + local answer + read -r -p "Docker Compose не найден. Установить Docker из официального репозитория? [Y/n]: " answer + answer="${answer:-Y}" + [[ "$answer" =~ ^[YyДд]$ ]] || fail "для запуска бота необходимы Docker Engine и Docker Compose" + + [[ -r /etc/os-release ]] || fail "не удалось определить дистрибутив Linux" + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + ubuntu|debian) ;; + *) fail "автоматическая установка Docker поддерживает Ubuntu и Debian; установите Docker вручную" ;; + esac + + info "Подключаю официальный репозиторий Docker" + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl + install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + + local codename architecture + codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" + [[ -n "$codename" ]] || fail "не удалось определить кодовое имя дистрибутива" + architecture="$(dpkg --print-architecture)" + cat > /etc/apt/sources.list.d/docker.sources </dev/null 2>&1 || fail "Docker Compose не запустился после установки" + info "Docker установлен" +} + +prompt_install_dir() { + local entered + read -r -p "Папка установки [${DEFAULT_INSTALL_DIR}]: " entered + INSTALL_DIR="${entered:-$DEFAULT_INSTALL_DIR}" + [[ "$INSTALL_DIR" == /* ]] || fail "путь установки должен быть абсолютным" + case "$INSTALL_DIR" in + /|/etc|/usr|/var|/opt) fail "выберите отдельную папку, например ${DEFAULT_INSTALL_DIR}" ;; + esac +} + +read_existing_value() { + local key="$1" + local env_file="$2" + [[ -f "$env_file" ]] || return 0 + sed -n "s/^${key}=//p" "$env_file" | tail -n 1 +} + +prompt_bot_settings() { + local env_file="${INSTALL_DIR}/.env" + local existing_token existing_timezone existing_report_time keep answer + existing_token="$(read_existing_value BOT_TOKEN "$env_file")" + existing_timezone="$(read_existing_value BOT_TIMEZONE "$env_file")" + existing_report_time="$(read_existing_value DAILY_REPORT_TIME "$env_file")" + + BOT_TOKEN="" + if [[ -n "$existing_token" ]]; then + read -r -p "Найдены прежние настройки. Сохранить текущий токен бота? [Y/n]: " keep + keep="${keep:-Y}" + if [[ "$keep" =~ ^[YyДд]$ ]]; then + BOT_TOKEN="$existing_token" + fi + fi + while [[ ! "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]]; do + read -r -s -p "Токен от @BotFather: " BOT_TOKEN + printf '\n' + [[ "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]] \ + || warn "Токен выглядит некорректно. Попробуйте ещё раз." + done + + local default_timezone="${existing_timezone:-Asia/Novosibirsk}" + while true; do + read -r -p "Часовой пояс [${default_timezone}]: " answer + BOT_TIMEZONE="${answer:-$default_timezone}" + if [[ "$BOT_TIMEZONE" =~ ^[A-Za-z0-9_+.-]+(/[A-Za-z0-9_+.-]+)+$ ]] \ + && [[ -e "/usr/share/zoneinfo/${BOT_TIMEZONE}" ]]; then + break + fi + warn "Неизвестный часовой пояс. Пример: Asia/Novosibirsk" + done + + local default_report_time="${existing_report_time:-21:00}" + while true; do + read -r -p "Время ежедневного отчёта [${default_report_time}]: " answer + DAILY_REPORT_TIME="${answer:-$default_report_time}" + if [[ "$DAILY_REPORT_TIME" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then + break + fi + warn "Введите время в формате ЧЧ:ММ, например 21:00" + done +} + +download_project() { + TMP_DIR="$(mktemp -d)" + local api_response download_url archive project_root + archive="${TMP_DIR}/finance-bot.zip" + + info "Получаю проект с Яндекс.Диска" + api_response="$(curl -fsS --get --data-urlencode "public_key=${PUBLIC_URL}" "$YANDEX_API")" \ + || fail "Яндекс.Диск не вернул ссылку на скачивание" + download_url="$(printf '%s' "$api_response" | python3 -c ' +import json, sys +try: + payload = json.load(sys.stdin) + print(payload["href"]) +except (KeyError, ValueError): + raise SystemExit(1) +')" || fail "не удалось разобрать ответ Яндекс.Диска; проверьте публичную ссылку" + + curl -fL --retry 3 --connect-timeout 20 "$download_url" -o "$archive" \ + || fail "не удалось скачать архив проекта" + [[ -s "$archive" ]] || fail "Яндекс.Диск вернул пустой архив" + if (( $(stat -c '%s' "$archive") > 209715200 )); then + fail "архив больше 200 МБ; установка остановлена" + fi + unzip -tq "$archive" >/dev/null || fail "скачанный файл не является исправным ZIP-архивом" + if unzip -Z1 "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + fail "в архиве обнаружены небезопасные пути" + fi + + mkdir -p "${TMP_DIR}/extracted" + unzip -q "$archive" -d "${TMP_DIR}/extracted" + COMPOSE_FILE="$(find "${TMP_DIR}/extracted" -maxdepth 4 -type f -name compose.yaml -print -quit)" + [[ -n "$COMPOSE_FILE" ]] || fail "в архиве не найден compose.yaml" + project_root="$(dirname "$COMPOSE_FILE")" + [[ -f "${project_root}/Dockerfile" && -f "${project_root}/pyproject.toml" && -d "${project_root}/app" ]] \ + || fail "структура проекта в архиве неполная" + DOWNLOADED_PROJECT_DIR="$project_root" + info "Проект скачан и проверен" +} + +deploy_project() { + mkdir -p "$INSTALL_DIR" + cp -a "${DOWNLOADED_PROJECT_DIR}/." "$INSTALL_DIR/" + + umask 077 + cat > "${INSTALL_DIR}/.env" <=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "finance-telegram-bot" +version = "1.0.0" +description = "Telegram bot for effortless personal income and expense tracking" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "aiogram>=3.29,<4", + "aiosqlite>=0.20,<1", + "tzdata>=2025.2", +] + +[project.optional-dependencies] +dev = ["pytest>=8,<9", "pytest-asyncio>=0.24,<1"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 0000000..c7153ce --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,39 @@ +import pytest + +from app.categories import classify, suggest_subcategory +from app.domain import Category + + +@pytest.mark.parametrize( + ("description", "category"), + [ + ("ярче", Category.PRODUCTS), + ("Пиво и снеки", Category.PRODUCTS), + ("бензин газпромнефть", Category.CAR), + ("платёж по ипотеке", Category.CREDITS), + ("билеты в кино", Category.ENTERTAINMENT), + ("обои в спальню", Category.RENOVATION), + ("5000 ремонт", Category.RENOVATION), + ("ремонт машины", Category.CAR), + ("подарок коллеге", Category.OTHER), + ], +) +def test_classification(description, category): + assert classify(description) is category + + +def test_keyword_does_not_match_inside_another_word(): + assert classify("корм для барсика") is Category.OTHER + + +@pytest.mark.parametrize( + ("description", "category", "subcategory"), + [ + ("3000 бензин", Category.CAR, "car_fuel"), + ("запчасти на машину", Category.CAR, "car_parts"), + ("пиво", Category.PRODUCTS, "products_alcohol"), + ("кофе в кафе", Category.PRODUCTS, "products_cafe"), + ], +) +def test_subcategory_suggestion(description, category, subcategory): + assert suggest_subcategory(category, description) == subcategory diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..56c9b61 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,257 @@ +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from app.database import Database +from app.database import FamilyJoinError +from app.categories import category_label +from app.domain import Category, TransactionKind + + +@pytest.mark.asyncio +async def test_transaction_lifecycle_and_totals(tmp_path): + database = Database(tmp_path / "finance.db") + await database.initialize() + now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC")) + await database.upsert_user( + user_id=10, + chat_id=10, + username="tester", + full_name="Test User", + timezone="UTC", + report_time="21:00", + now=now, + ) + await database.upsert_user( + user_id=20, + chat_id=20, + username=None, + full_name="Other User", + timezone="UTC", + report_time="21:00", + now=now, + ) + + expense = await database.add_transaction( + user_id=10, + amount_kopecks=30_000, + description="бензин", + kind=TransactionKind.EXPENSE, + category=Category.CAR, + occurred_at=now, + ) + await database.add_transaction( + user_id=10, + amount_kopecks=100_000, + description="подработка", + kind=TransactionKind.INCOME, + category=Category.INCOME, + occurred_at=now, + ) + + totals = await database.totals(10, now - timedelta(hours=1), now + timedelta(hours=1)) + assert totals.income == 100_000 + assert totals.expense == 30_000 + assert totals.balance == 70_000 + assert await database.category_totals( + 10, now - timedelta(hours=1), now + timedelta(hours=1) + ) == {category_label(Category.CAR): 30_000} + assert await database.category_totals(10) == {category_label(Category.CAR): 30_000} + + # Другой пользователь не может изменить или удалить чужую запись. + assert not await database.update_category(expense.id, 20, Category.OTHER) + assert not await database.delete_transaction(expense.id, 20) + assert await database.update_category(expense.id, 10, Category.OTHER) + assert (await database.get_transaction(expense.id, 10)).category is Category.OTHER + assert await database.delete_transaction(expense.id, 10) + + +@pytest.mark.asyncio +async def test_daily_report_idempotency(tmp_path): + database = Database(tmp_path / "finance.db") + await database.initialize() + now = datetime(2026, 7, 13, 21, tzinfo=ZoneInfo("UTC")) + await database.upsert_user( + user_id=10, + chat_id=10, + username=None, + full_name="Test User", + timezone="UTC", + report_time="21:00", + now=now, + ) + assert not await database.report_was_sent(10, "2026-07-13") + await database.mark_report_sent(10, "2026-07-13", now) + await database.mark_report_sent(10, "2026-07-13", now) + assert await database.report_was_sent(10, "2026-07-13") + + +@pytest.mark.asyncio +async def test_family_budget_shares_history_and_new_transactions(tmp_path): + database = Database(tmp_path / "finance.db") + await database.initialize() + now = datetime(2026, 7, 13, 20, tzinfo=ZoneInfo("UTC")) + for user_id, name in ((10, "Алексей"), (20, "Мария"), (30, "Посторонний")): + await database.upsert_user( + user_id=user_id, + chat_id=user_id, + username=None, + full_name=name, + timezone="UTC", + report_time="21:00", + now=now, + ) + + await database.add_transaction( + user_id=10, + amount_kopecks=30_000, + description="бензин", + kind=TransactionKind.EXPENSE, + category=Category.CAR, + occurred_at=now, + ) + await database.add_transaction( + user_id=20, + amount_kopecks=10_000, + description="ярче", + kind=TransactionKind.EXPENSE, + category=Category.PRODUCTS, + occurred_at=now, + ) + + family = await database.create_family(10, now) + request = await database.request_family_join(20, family.invite_code.lower(), now) + assert request.owner_chat_id == 10 + with pytest.raises(FamilyJoinError, match="обработан"): + await database.resolve_family_join( + owner_user_id=30, + candidate_user_id=20, + approve=True, + now=now, + ) + approved, candidate_chat_id, joined = await database.resolve_family_join( + owner_user_id=10, + candidate_user_id=20, + approve=True, + now=now, + ) + assert approved + assert candidate_chat_id == 20 + assert joined is not None + assert joined.household_id == family.household_id + assert joined.members == ("Алексей", "Мария") + + for user_id in (10, 20): + totals = await database.totals(user_id) + assert totals.expense == 40_000 + assert await database.category_totals(user_id) == { + category_label(Category.CAR): 30_000, + category_label(Category.PRODUCTS): 10_000, + } + recent = await database.recent_transactions(user_id) + assert {item.author_name for item in recent} == {"Алексей", "Мария"} + + recipients = await database.family_recipients(10) + assert [(row["user_id"], row["chat_id"]) for row in recipients] == [(20, 20)] + with pytest.raises(FamilyJoinError, match="создатель"): + await database.bind_family_chat(20, -100500, "Наш бюджет") + bound = await database.bind_family_chat(10, -100500, "Наш бюджет") + assert bound.notification_chat_id == -100500 + assert await database.family_notification_chats(10) == [20, -100500] + assert await database.family_report_target(20) == (family.household_id, -100500) + assert not await database.family_report_was_sent(family.household_id, "2026-07-13") + await database.mark_family_report_sent(family.household_id, "2026-07-13", now) + assert await database.family_report_was_sent(family.household_id, "2026-07-13") + + with pytest.raises(FamilyJoinError, match="создатель"): + await database.set_family_debt(20, 53_071_300, now) + await database.set_family_debt(10, 53_071_300, now) + salary = await database.add_transaction( + user_id=20, + amount_kopecks=40_000_000, + description="зарплата", + kind=TransactionKind.INCOME, + category=Category.INCOME, + occurred_at=now + timedelta(seconds=1), + ) + assert await database.family_debt(10) == 13_071_300 + purchase = await database.add_transaction( + user_id=10, + amount_kopecks=2_000_000, + description="покупка", + kind=TransactionKind.EXPENSE, + category=Category.OTHER, + occurred_at=now + timedelta(seconds=2), + ) + assert await database.family_debt(20) == 15_071_300 + assert await database.delete_transaction(purchase.id, 10) + assert await database.family_debt(10) == 13_071_300 + assert salary.author_name == "Мария" + + categories = await database.budget_categories(10) + fuel = next(category for category in categories if category.system_key == "car_fuel") + assert fuel.parent_name == "Машина" + health = await database.create_budget_category( + user_id=20, + name="Здоровье", + parent_id=None, + now=now, + ) + doctors = await database.create_budget_category( + user_id=10, + name="Врачи", + parent_id=health.id, + now=now, + ) + other = await database.add_transaction( + user_id=10, + amount_kopecks=50_000, + description="консультация", + kind=TransactionKind.EXPENSE, + category=Category.OTHER, + occurred_at=now + timedelta(seconds=3), + ) + assert await database.update_transaction_budget_category(other.id, 10, doctors.id) + moved = await database.get_transaction(other.id, 10) + assert moved.parent_category_name == "Здоровье" + assert moved.budget_category_name == "Врачи" + analytics = await database.category_totals(20) + assert analytics["📁 Здоровье → 📁 Врачи"] == 50_000 + with pytest.raises(FamilyJoinError, match="два участника"): + await database.request_family_join(30, family.invite_code, now) + + +@pytest.mark.asyncio +async def test_existing_database_is_migrated_for_family_budget(tmp_path): + import aiosqlite + + path = tmp_path / "old.db" + async with aiosqlite.connect(path) as db: + await db.executescript( + """ + CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, chat_id INTEGER NOT NULL, username TEXT, + full_name TEXT NOT NULL, timezone TEXT NOT NULL, report_time TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + amount_kopecks INTEGER NOT NULL, description TEXT NOT NULL, + kind TEXT NOT NULL, category TEXT NOT NULL, + occurred_at TEXT NOT NULL, created_at TEXT NOT NULL + ); + """ + ) + await db.commit() + + database = Database(path) + await database.initialize() + async with aiosqlite.connect(path) as db: + cursor = await db.execute("PRAGMA table_info(transactions)") + columns = {row[1] for row in await cursor.fetchall()} + assert "household_id" in columns + async with aiosqlite.connect(path) as db: + cursor = await db.execute("PRAGMA table_info(households)") + household_columns = {row[1] for row in await cursor.fetchall()} + assert {"debt_base_kopecks", "debt_started_at"} <= household_columns diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..4f3ece4 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,41 @@ +import pytest + +from app.domain import TransactionKind +from app.parser import ParseError, format_money, parse_money_amount, parse_transaction + + +@pytest.mark.parametrize( + ("text", "kopecks", "description", "kind"), + [ + ("467 ярче", 46_700, "ярче", TransactionKind.EXPENSE), + ("3 000 бензин", 300_000, "бензин", TransactionKind.EXPENSE), + ("199,90 кофе", 19_990, "кофе", TransactionKind.EXPENSE), + ("+ 85 000 зарплата", 8_500_000, "зарплата", TransactionKind.INCOME), + ("доход 5000 подработка", 500_000, "подработка", TransactionKind.INCOME), + ("расход 1200 кино", 120_000, "кино", TransactionKind.EXPENSE), + ], +) +def test_parse_transaction(text, kopecks, description, kind): + parsed = parse_transaction(text) + assert parsed.amount_kopecks == kopecks + assert parsed.description == description + assert parsed.kind is kind + + +@pytest.mark.parametrize("text", ["просто текст", "0 кофе", "500", "- доход 500 премия"]) +def test_rejects_invalid_input(text): + with pytest.raises(ParseError): + parse_transaction(text) + + +def test_format_money(): + assert format_money(123_456_00) == "123 456 ₽" + assert format_money(-199_90, signed=True) == "−199,90 ₽" + + +@pytest.mark.parametrize( + ("text", "kopecks"), + [("530713", 53_071_300), ("530 713 ₽", 53_071_300), ("1250,50 руб", 125_050)], +) +def test_parse_money_amount(text, kopecks): + assert parse_money_amount(text) == kopecks diff --git a/tests/test_periods.py b/tests/test_periods.py new file mode 100644 index 0000000..16c6f9d --- /dev/null +++ b/tests/test_periods.py @@ -0,0 +1,19 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from app.periods import day_bounds, month_bounds + + +def test_month_bounds_in_december(): + now = datetime(2026, 12, 15, 10, tzinfo=ZoneInfo("UTC")) + start, end = month_bounds(now) + assert start == datetime(2026, 12, 1, tzinfo=ZoneInfo("UTC")) + assert end == datetime(2027, 1, 1, tzinfo=ZoneInfo("UTC")) + + +def test_day_bounds(): + now = datetime(2026, 7, 13, 22, 10, tzinfo=ZoneInfo("UTC")) + start, end = day_bounds(now) + assert start.day == 13 + assert end.day == 14 + diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..0eda56b --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,51 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from app.database import Database +from app.scheduler import _send_due_reports + + +class FakeBot: + def __init__(self): + self.messages = [] + + async def send_message(self, **kwargs): + self.messages.append(kwargs) + + +@pytest.mark.asyncio +async def test_family_daily_report_is_sent_to_group_only_once(tmp_path): + database = Database(tmp_path / "finance.db") + await database.initialize() + now = datetime.now(ZoneInfo("UTC")) + for user_id, name in ((10, "Алексей"), (20, "Мария")): + await database.upsert_user( + user_id=user_id, + chat_id=user_id, + username=None, + full_name=name, + timezone="UTC", + report_time="00:00", + now=now, + ) + family = await database.create_family(10, now) + await database.request_family_join(20, family.invite_code, now) + await database.resolve_family_join( + owner_user_id=10, + candidate_user_id=20, + approve=True, + now=now, + ) + await database.bind_family_chat(10, -100500, "Наш бюджет") + + bot = FakeBot() + await _send_due_reports(bot, database) + assert [message["chat_id"] for message in bot.messages].count(10) == 1 + assert [message["chat_id"] for message in bot.messages].count(20) == 1 + assert [message["chat_id"] for message in bot.messages].count(-100500) == 1 + + await _send_due_reports(bot, database) + assert len(bot.messages) == 3 +