Initial release of family finance Telegram bot
This commit is contained in:
commit
2ce385554f
25 changed files with 3254 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
tests
|
||||||
|
data
|
||||||
|
|
||||||
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -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
|
||||||
|
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
||||||
158
README.md
Normal file
158
README.md
Normal file
|
|
@ -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`.
|
||||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""Personal finance Telegram bot."""
|
||||||
|
|
||||||
68
app/__main__.py
Normal file
68
app/__main__.py
Normal file
|
|
@ -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())
|
||||||
129
app/categories.py
Normal file
129
app/categories.py
Normal file
|
|
@ -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
|
||||||
44
app/config.py
Normal file
44
app/config.py
Normal file
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
||||||
884
app/database.py
Normal file
884
app/database.py
Normal file
|
|
@ -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<?")
|
||||||
|
params.append(end.isoformat())
|
||||||
|
async with aiosqlite.connect(self.path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT t.category, c.name, c.icon, p.name, p.icon,
|
||||||
|
SUM(t.amount_kopecks) AS total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN budget_categories c ON c.id=t.budget_category_id
|
||||||
|
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
GROUP BY t.category, c.id, p.id ORDER BY total DESC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for legacy_key, name, icon, parent_name, parent_icon, total in rows:
|
||||||
|
if name and parent_name:
|
||||||
|
label = f"{parent_icon or '📁'} {parent_name} → {icon or '📁'} {name}"
|
||||||
|
elif name:
|
||||||
|
label = f"{icon or '📁'} {name}"
|
||||||
|
else:
|
||||||
|
label = category_label(Category(legacy_key))
|
||||||
|
result[label] = result.get(label, 0) + int(total)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def recent_transactions(self, user_id: int, limit: int = 10) -> 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=?)
|
||||||
|
)"""
|
||||||
88
app/domain.py
Normal file
88
app/domain.py
Normal file
|
|
@ -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
|
||||||
592
app/handlers.py
Normal file
592
app/handlers.py
Normal file
|
|
@ -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(
|
||||||
|
"✅ <b>Чат подключён к семейному бюджету.</b>\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 = ["🗂 <b>Категории семейного бюджета</b>", ""]
|
||||||
|
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(
|
||||||
|
"Введите название новой категории, например <code>Здоровье</code>.\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(
|
||||||
|
"Введите название подкатегории, например <code>Врачи</code>.\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"✅ Добавлено: <b>{escape(category.display_name)}</b>",
|
||||||
|
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"
|
||||||
|
"Например: <code>530713</code> или <code>530 713 ₽</code>\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"💳 <b>{escape(message.from_user.full_name)} обновил(а) общий долг.</b>\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"
|
||||||
|
"Например: <code>FIN-ABC123</code>\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(
|
||||||
|
"🔐 <b>Запрос отправлен владельцу бюджета.</b>\n\n"
|
||||||
|
"Доступ появится только после его подтверждения.",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
request.owner_chat_id,
|
||||||
|
f"🔐 <b>{escape(request.candidate_name)} просит доступ к семейному бюджету.</b>\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"🗂 <b>{escape(callback.from_user.full_name)} изменил(а) категорию записи.</b>\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"🗑 <i>Запись удалена</i>{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"
|
||||||
|
"Напишите сумму и описание, например: <code>467 ярче</code>\n"
|
||||||
|
"Доход: <code>+ 50000 зарплата</code>"
|
||||||
|
)
|
||||||
|
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("Пока я понимаю только текстовые записи — например, <code>3000 бензин</code>.")
|
||||||
|
|
||||||
|
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
|
||||||
152
app/keyboards.py
Normal file
152
app/keyboards.py
Normal file
|
|
@ -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"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
210
app/messages.py
Normal file
210
app/messages.py
Normal file
|
|
@ -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 = """<b>Привет! Я помогу держать деньги под контролем 👋</b>
|
||||||
|
|
||||||
|
Просто присылай сумму и описание — я сам определю категорию:
|
||||||
|
|
||||||
|
<code>467 ярче</code>
|
||||||
|
<code>3000 бензин</code>
|
||||||
|
<code>+ 85000 зарплата</code>
|
||||||
|
|
||||||
|
Обычная запись — расход. Знак <b>+</b> или слово <b>доход</b> — поступление.
|
||||||
|
|
||||||
|
Каждый вечер я пришлю короткий итог дня и месяца. А в разделе «Семья» можно подключить общий бюджет с супругом. Начнём?"""
|
||||||
|
|
||||||
|
|
||||||
|
HELP_TEXT = """<b>Как записывать операции</b>
|
||||||
|
|
||||||
|
Расход:
|
||||||
|
<code>1250 продукты</code>
|
||||||
|
<code>3 500 ремонт машины</code>
|
||||||
|
|
||||||
|
Доход:
|
||||||
|
<code>+ 75000 зарплата</code>
|
||||||
|
<code>доход 5000 подработка</code>
|
||||||
|
|
||||||
|
Можно вводить копейки: <code>199,90 кофе</code>.
|
||||||
|
|
||||||
|
После записи категорию можно изменить, а ошибочную операцию — удалить. Кнопки внизу показывают итоги и аналитику.
|
||||||
|
|
||||||
|
В семейном режиме кнопка <b>«Общий долг»</b> включает автоматический расчёт: расходы увеличивают долг, доходы уменьшают.
|
||||||
|
|
||||||
|
Через <b>«Категории»</b> можно добавить свои категории и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое»."""
|
||||||
|
|
||||||
|
|
||||||
|
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} <b>{heading}</b>\n\n"
|
||||||
|
f"<b>{format_money(signed_amount, signed=True)}</b> · {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"<b>{escape(title)}</b>\n\n"
|
||||||
|
f"↗️ Доходы <b>{format_money(totals.income)}</b>\n"
|
||||||
|
f"↘️ Расходы <b>{format_money(totals.expense)}</b>\n"
|
||||||
|
f"━━━━━━━━━━━━\n"
|
||||||
|
f"{balance_icon} Баланс <b>{format_money(totals.balance, signed=True)}</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def analytics_text(period_name: str, category_totals: dict, total: int) -> str:
|
||||||
|
title = f"📊 <b>Расходы {escape(period_name)}</b>"
|
||||||
|
if total == 0:
|
||||||
|
return f"{title}\n\nПока расходов нет. Самое время записать первый 👇"
|
||||||
|
|
||||||
|
lines = [title, f"Всего: <b>{format_money(total)}</b>", ""]
|
||||||
|
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"<code>{bar}</code> {percent}% · <b>{format_money(amount)}</b>")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def recent_text(transactions: list[Transaction]) -> str:
|
||||||
|
if not transactions:
|
||||||
|
return "🧾 <b>Последние записи</b>\n\nЗдесь пока пусто."
|
||||||
|
lines = ["🧾 <b>Последние записи</b>", ""]
|
||||||
|
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} <b>{format_money(amount, signed=True)}</b> "
|
||||||
|
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 (
|
||||||
|
"👨👩👧 <b>Семейный бюджет</b>\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💬 Общий чат: <b>{escape(info.notification_chat_title or 'Подключён')}</b>"
|
||||||
|
if info.notification_chat_id is not None
|
||||||
|
else (
|
||||||
|
"\n\n💬 <b>Общий чат пока не подключён.</b> Создатель бюджета должен добавить "
|
||||||
|
"бота в семейную группу, стать администратором группы и отправить там /family_chat."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"👨👩👧 <b>Семейный бюджет подключён</b>\n\n"
|
||||||
|
f"<b>Участники:</b>\n{members}\n\n"
|
||||||
|
f"Код приглашения: <code>{escape(info.invite_code)}</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"👨👩👧 <b>{author} {action}</b>\n\n"
|
||||||
|
f"{icon} <b>{format_money(amount, signed=True)}</b> · {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 (
|
||||||
|
"🌙 <b>Итог дня</b>\n\n"
|
||||||
|
f"Потрачено сегодня: <b>{format_money(day.expense)}</b>\n"
|
||||||
|
f"Получено сегодня: <b>{format_money(day.income)}</b>\n"
|
||||||
|
f"{balance_icon} Баланс дня: <b>{format_money(day.balance, signed=True)}</b>\n\n"
|
||||||
|
"<b>С начала месяца</b>\n"
|
||||||
|
f"Расходы: <b>{format_money(month.expense)}</b>\n"
|
||||||
|
f"Доходы: <b>{format_money(month.income)}</b>\n"
|
||||||
|
f"Баланс: <b>{format_money(month.balance, signed=True)}</b>"
|
||||||
|
f"{debt_section}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def debt_short_text(debt: int) -> str:
|
||||||
|
if debt > 0:
|
||||||
|
return f"💳 Общий долг: <b>{format_money(debt)}</b>"
|
||||||
|
if debt < 0:
|
||||||
|
return f"🌿 Семейный резерв: <b>{format_money(abs(debt))}</b>"
|
||||||
|
return "🎉 Общий долг: <b>погашен</b>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<b>💳 Общий долг</b>\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)
|
||||||
84
app/parser.py
Normal file
84
app/parser.py
Normal file
|
|
@ -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<sign>[+-])?\s*"
|
||||||
|
r"(?:(?P<kind>доход|приход|расход|трата)\s+)?"
|
||||||
|
r"(?P<amount>\d[\d ]*(?:[,.]\d{1,2})?)\s*"
|
||||||
|
r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s+"
|
||||||
|
r"(?P<description>\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<amount>\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))
|
||||||
18
app/periods.py
Normal file
18
app/periods.py
Normal file
|
|
@ -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
|
||||||
|
|
||||||
80
app/scheduler.py
Normal file
80
app/scheduler.py
Normal file
|
|
@ -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
|
||||||
12
compose.yaml
Normal file
12
compose.yaml
Normal file
|
|
@ -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:
|
||||||
|
|
||||||
251
install.sh
Normal file
251
install.sh
Normal file
|
|
@ -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 <<EOF
|
||||||
|
Types: deb
|
||||||
|
URIs: https://download.docker.com/linux/${ID}
|
||||||
|
Suites: ${codename}
|
||||||
|
Components: stable
|
||||||
|
Architectures: ${architecture}
|
||||||
|
Signed-By: /etc/apt/keyrings/docker.asc
|
||||||
|
EOF
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
systemctl enable --now docker
|
||||||
|
docker compose version >/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" <<EOF
|
||||||
|
BOT_TOKEN=${BOT_TOKEN}
|
||||||
|
BOT_TIMEZONE=${BOT_TIMEZONE}
|
||||||
|
DAILY_REPORT_TIME=${DAILY_REPORT_TIME}
|
||||||
|
DATABASE_PATH=/app/data/finance.db
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
chmod 600 "${INSTALL_DIR}/.env"
|
||||||
|
|
||||||
|
info "Собираю и запускаю контейнер"
|
||||||
|
docker compose --project-directory "$INSTALL_DIR" -f "${INSTALL_DIR}/compose.yaml" \
|
||||||
|
up -d --build --remove-orphans
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
local status
|
||||||
|
status="$(docker inspect --format '{{.State.Status}}' finance-telegram-bot 2>/dev/null || true)"
|
||||||
|
if [[ "$status" != "running" ]]; then
|
||||||
|
docker compose --project-directory "$INSTALL_DIR" -f "${INSTALL_DIR}/compose.yaml" \
|
||||||
|
logs --tail=40 finance-bot || true
|
||||||
|
fail "контейнер не запустился; выше показаны последние строки журнала"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Бот установлен и запущен"
|
||||||
|
printf '\n%b\n' "${BOLD}Папка:${RESET} ${INSTALL_DIR}"
|
||||||
|
printf '%b\n' "${BOLD}Статус:${RESET} docker compose -f ${INSTALL_DIR}/compose.yaml ps"
|
||||||
|
printf '%b\n' "${BOLD}Журнал:${RESET} docker compose -f ${INSTALL_DIR}/compose.yaml logs -f"
|
||||||
|
printf '%b\n' "Повторный запуск этого установщика обновит код и сохранит базу данных."
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
printf '%b\n\n' "${BOLD}Установка финансового Telegram-бота${RESET}"
|
||||||
|
require_root
|
||||||
|
install_base_tools
|
||||||
|
prompt_install_dir
|
||||||
|
prompt_bot_settings
|
||||||
|
install_docker
|
||||||
|
download_project
|
||||||
|
deploy_project
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=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*"]
|
||||||
39
tests/test_categories.py
Normal file
39
tests/test_categories.py
Normal file
|
|
@ -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
|
||||||
257
tests/test_database.py
Normal file
257
tests/test_database.py
Normal file
|
|
@ -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
|
||||||
41
tests/test_parser.py
Normal file
41
tests/test_parser.py
Normal file
|
|
@ -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
|
||||||
19
tests/test_periods.py
Normal file
19
tests/test_periods.py
Normal file
|
|
@ -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
|
||||||
|
|
||||||
51
tests/test_scheduler.py
Normal file
51
tests/test_scheduler.py
Normal file
|
|
@ -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
|
||||||
|
|
||||||
Loading…
Reference in a new issue