616 lines
27 KiB
Python
616 lines
27 KiB
Python
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_kind_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 ""
|
||
kind_icon = "↗️" if category.kind == "income" else "↘️"
|
||
lines.append(f"{prefix}{kind_icon} {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 choose_main_category_kind(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await state.set_data({"parent_id": None})
|
||
if callback.message:
|
||
await callback.message.answer(
|
||
"Как операции этой категории влияют на бюджет и общий долг?",
|
||
reply_markup=category_kind_keyboard(),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("catmanage:kind:"))
|
||
async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None:
|
||
kind = (callback.data or "").rsplit(":", maxsplit=1)[-1]
|
||
if kind not in {"expense", "income"}:
|
||
await callback.answer("Некорректный тип категории", show_alert=True)
|
||
return
|
||
await state.set_state(CategorySetup.waiting_for_name)
|
||
await state.update_data(kind=kind)
|
||
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"),
|
||
kind=data.get("kind", "expense"),
|
||
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:
|
||
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
|
||
)
|
||
if parsed.kind is TransactionKind.INCOME and any(
|
||
word in {"зп", "зарплата"}
|
||
for word in parsed.description.casefold().replace("ё", "е").split()
|
||
):
|
||
budget_category_id = await database.resolve_salary_category(message.from_user.id)
|
||
transaction = await database.add_transaction(
|
||
user_id=message.from_user.id,
|
||
amount_kopecks=parsed.amount_kopecks,
|
||
description=parsed.description,
|
||
kind=parsed.kind,
|
||
category=category,
|
||
debt_effect=parsed.debt_effect,
|
||
debt_effect_override=parsed.debt_effect_override,
|
||
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
|