from __future__ import annotations from datetime import date from aiogram import F, Router from aiogram.filters import Command, CommandObject, CommandStart, StateFilter from aiogram.fsm.context import FSMContext from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, ) from bot.clients.budget_api import BudgetApiError from bot.handlers.yandex_gate import YandexLoginGateMiddleware from bot.handlers.states import BudgetSetup, DatedExpense from bot.services.budget import BudgetService, PeriodExpensesPage from bot.services.parsing import ( format_date, format_money, format_status, parse_amount, parse_end_date, parse_expense_message, parse_spent_date, ) router = Router() router.message.middleware(YandexLoginGateMiddleware()) router.callback_query.middleware(YandexLoginGateMiddleware()) HELP_TEXT = """\ Я помогаю дотянуть до зарплаты без сюрпризов. Команды: /budget — новый бюджет (имя · сумма · дата) /budgets — список бюджетов, выбрать / вкл-выкл / удалить /status — сколько можно тратить сегодня /today — траты за сегодня /history — траты за весь период (постранично) /day 12.09 — траты за дату /spend — трата за другую дату (диалог) /undo — отменить последнюю трату /cancel — отменить текущий диалог /help — эта справка Быстрый ввод трат: • 250 • 250 кофе • кофе 250 • за 12.09 250 кофе • 250 кофе за 12.09 • 12.09 250 кофе """ MENU_STATUS = "📊 Статус" MENU_TODAY = "🧾 Сегодня" MENU_HISTORY = "📒 Период" MENU_DATED = "📅 За дату" MENU_UNDO = "↩️ Отмена траты" MENU_BUDGET = "💰 Новый бюджет" MENU_BUDGETS = "🗂 Бюджеты" HISTORY_CB_PREFIX = "hist:" BUDGET_SELECT_PREFIX = "bsel:" BUDGET_TOGGLE_PREFIX = "btgl:" BUDGET_DELETE_PREFIX = "bdel:" def main_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text=MENU_STATUS), KeyboardButton(text=MENU_TODAY)], [KeyboardButton(text=MENU_HISTORY), KeyboardButton(text=MENU_DATED)], [KeyboardButton(text=MENU_BUDGETS), KeyboardButton(text=MENU_BUDGET)], [KeyboardButton(text=MENU_UNDO)], ], resize_keyboard=True, ) def format_period_page(page_data: PeriodExpensesPage) -> str: budget = page_data.budget header = ( f"📒 {budget.name}\n" f"Траты {format_date(budget.start_date)}–{format_date(budget.end_date)}\n" f"Всего записей: {page_data.total_count} · " f"сумма: {format_money(page_data.total_sum)}\n" f"Страница {page_data.page + 1}/{page_data.total_pages}" ) if not page_data.items: return header + "\n\nПока нет трат за период." lines = [] for item in page_data.items: note = f" — {item.note}" if item.note else "" lines.append( f"• {format_date(item.spent_at)} · {format_money(item.amount)}{note}" ) return header + "\n\n" + "\n".join(lines) def format_budgets_list(items) -> str: if not items: return "Бюджетов пока нет. Создай: /budget" lines = ["Твои бюджеты:"] for status in items: b = status.budget marks = [] if status.selected: marks.append("текущий") marks.append("активен" if b.is_active else "выкл") mark = ", ".join(marks) lines.append( f"• #{b.id} {b.name} — {format_money(b.total_amount)} " f"до {format_date(b.end_date)} ({mark})" ) lines.append("\nКнопки: выбрать · вкл/выкл · удалить.") return "\n".join(lines) def budgets_keyboard(items) -> InlineKeyboardMarkup | None: if not items: return None rows: list[list[InlineKeyboardButton]] = [] for status in items: b = status.budget select_label = f"{'✓ ' if status.selected else ''}{b.name}"[:28] toggle_label = "Выкл" if b.is_active else "Вкл" rows.append( [ InlineKeyboardButton( text=select_label, callback_data=f"{BUDGET_SELECT_PREFIX}{b.id}", ), InlineKeyboardButton( text=toggle_label, callback_data=f"{BUDGET_TOGGLE_PREFIX}{b.id}", ), InlineKeyboardButton( text="🗑", callback_data=f"{BUDGET_DELETE_PREFIX}{b.id}", ), ] ) return InlineKeyboardMarkup(inline_keyboard=rows) def history_keyboard(page_data: PeriodExpensesPage) -> InlineKeyboardMarkup | None: if page_data.total_pages <= 1: return None buttons: list[InlineKeyboardButton] = [] if page_data.page > 0: buttons.append( InlineKeyboardButton( text="‹ Назад", callback_data=f"{HISTORY_CB_PREFIX}{page_data.page - 1}", ) ) buttons.append( InlineKeyboardButton( text=f"{page_data.page + 1}/{page_data.total_pages}", callback_data=f"{HISTORY_CB_PREFIX}nop", ) ) if page_data.page + 1 < page_data.total_pages: buttons.append( InlineKeyboardButton( text="Вперёд ›", callback_data=f"{HISTORY_CB_PREFIX}{page_data.page + 1}", ) ) return InlineKeyboardMarkup(inline_keyboard=[buttons]) async def _reply_expense_saved( message: Message, *, amount: float, note: str | None, spent_at: date | None, status, ) -> None: note_part = f" ({note})" if note else "" date_part = f" за {format_date(spent_at)}" if spent_at else "" await message.answer( f"Записал {format_money(amount)}{note_part}{date_part}.\n\n" f"{format_status(status)}", reply_markup=main_keyboard(), ) async def _save_expense( message: Message, budget_service: BudgetService, *, amount: float, note: str | None, spent_at: date | None, state: FSMContext | None = None, ) -> bool: try: status = await budget_service.add_expense( user_id=message.from_user.id, amount=amount, note=note, spent_at=spent_at, ) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) if state is not None: await state.clear() return False except BudgetApiError as exc: await message.answer( f"API недоступен: {exc}", reply_markup=main_keyboard(), ) if state is not None: await state.clear() return False if state is not None: await state.clear() await _reply_expense_saved( message, amount=amount, note=note, spent_at=spent_at, status=status, ) return True # --- Global commands / menu (always win over FSM) --- @router.message(CommandStart()) async def cmd_start(message: Message, state: FSMContext) -> None: await state.clear() await message.answer( "Привет! Я бот «от зарплаты до зарплаты».\n\n" "1) Задай бюджет: /budget\n" "2) Пиши траты: 250 или кофе 250\n" "3) За другую дату: за 12.09 250 кофе\n" "4) Смотри лимит: /status", reply_markup=main_keyboard(), ) @router.message(Command("help")) async def cmd_help(message: Message, state: FSMContext) -> None: await state.clear() await message.answer(HELP_TEXT, reply_markup=main_keyboard()) @router.message(Command("cancel")) @router.message(F.text.casefold() == "отмена") async def cmd_cancel(message: Message, state: FSMContext) -> None: current = await state.get_state() if current is None: await message.answer("Нечего отменять.", reply_markup=main_keyboard()) return await state.clear() await message.answer("Ок, отменил.", reply_markup=main_keyboard()) @router.message(Command("status")) @router.message(F.text == MENU_STATUS) async def cmd_status( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() try: status = await budget_service.get_status(message.from_user.id) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) return await message.answer(format_status(status), reply_markup=main_keyboard()) @router.message(Command("today")) @router.message(F.text == MENU_TODAY) async def cmd_today( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() try: status = await budget_service.get_status(message.from_user.id) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) return expenses = await budget_service.today_expenses(message.from_user.id) if not expenses: body = "Сегодня трат пока нет." else: lines = [] for item in expenses: note = f" — {item.note}" if item.note else "" lines.append(f"• {format_money(item.amount)}{note}") body = "Траты сегодня:\n" + "\n".join(lines) await message.answer( f"{body}\n\n{format_status(status)}", reply_markup=main_keyboard(), ) @router.message(Command("history")) @router.message(F.text == MENU_HISTORY) async def cmd_history( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() try: page_data = await budget_service.get_period_expenses_page( message.from_user.id, page=0, ) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) return await message.answer( format_period_page(page_data), reply_markup=history_keyboard(page_data) or main_keyboard(), ) @router.callback_query(F.data.startswith(HISTORY_CB_PREFIX)) async def cb_history_page( callback: CallbackQuery, budget_service: BudgetService, ) -> None: raw = (callback.data or "")[len(HISTORY_CB_PREFIX) :] if raw == "nop": await callback.answer() return try: page = int(raw) except ValueError: await callback.answer("Некорректная страница", show_alert=True) return try: page_data = await budget_service.get_period_expenses_page( callback.from_user.id, page=page, ) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return text = format_period_page(page_data) markup = history_keyboard(page_data) if callback.message: await callback.message.edit_text(text, reply_markup=markup) await callback.answer() @router.message(Command("day")) async def cmd_day( message: Message, command: CommandObject, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() args = (command.args or "").strip() if not args: await message.answer( "Укажи дату: /day 12.09\n" "Или список трат за сегодня: /today", reply_markup=main_keyboard(), ) return try: day = parse_spent_date(args) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) return try: status = await budget_service.get_status(message.from_user.id) except ValueError as exc: await message.answer(str(exc), reply_markup=main_keyboard()) return expenses = await budget_service.expenses_on_date(message.from_user.id, day) if not expenses: body = f"За {format_date(day)} трат нет." else: lines = [] for item in expenses: note = f" — {item.note}" if item.note else "" lines.append(f"• {format_money(item.amount)}{note}") body = f"Траты за {format_date(day)}:\n" + "\n".join(lines) await message.answer( f"{body}\n\n{format_status(status)}", reply_markup=main_keyboard(), ) @router.message(Command("undo")) @router.message(F.text == MENU_UNDO) async def cmd_undo( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() status, amount = await budget_service.undo_last_expense(message.from_user.id) if amount is None: await message.answer("Нечего отменять.", reply_markup=main_keyboard()) return text = f"Удалил последнюю трату: {format_money(amount)}." if status is not None: text += f"\n\n{format_status(status)}" await message.answer(text, reply_markup=main_keyboard()) @router.message(Command("budget")) @router.message(F.text == MENU_BUDGET) async def cmd_budget(message: Message, state: FSMContext) -> None: await state.set_state(BudgetSetup.waiting_name) await message.answer( "Новый бюджет. Как назвать? (например: Зарплата, Отпуск)\n" "Или «-» чтобы оставить «Бюджет».\n" "Отмена: /cancel", reply_markup=ReplyKeyboardRemove(), ) @router.message(Command("budgets")) @router.message(F.text == MENU_BUDGETS) async def cmd_budgets( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: await state.clear() try: items = await budget_service.list_user_statuses(message.from_user.id) except (ValueError, BudgetApiError) as exc: await message.answer(f"Не удалось загрузить: {exc}", reply_markup=main_keyboard()) return await message.answer( format_budgets_list(items), reply_markup=budgets_keyboard(items) or main_keyboard(), ) @router.callback_query(F.data.startswith(BUDGET_SELECT_PREFIX)) async def cb_budget_select( callback: CallbackQuery, budget_service: BudgetService, ) -> None: raw = (callback.data or "")[len(BUDGET_SELECT_PREFIX) :] try: budget_id = int(raw) except ValueError: await callback.answer("Некорректный id", show_alert=True) return try: status = await budget_service.select_budget(callback.from_user.id, budget_id) items = await budget_service.list_user_statuses(callback.from_user.id) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return if callback.message: await callback.message.edit_text( format_budgets_list(items) + f"\n\nТекущий: {status.budget.name}", reply_markup=budgets_keyboard(items), ) await callback.answer(f"Выбран: {status.budget.name}") @router.callback_query(F.data.startswith(BUDGET_TOGGLE_PREFIX)) async def cb_budget_toggle( callback: CallbackQuery, budget_service: BudgetService, ) -> None: raw = (callback.data or "")[len(BUDGET_TOGGLE_PREFIX) :] try: budget_id = int(raw) except ValueError: await callback.answer("Некорректный id", show_alert=True) return try: current = await budget_service.get_status( callback.from_user.id, budget_id=budget_id, ) status = await budget_service.set_budget_active( callback.from_user.id, budget_id, not current.budget.is_active, ) items = await budget_service.list_user_statuses(callback.from_user.id) except ValueError as exc: await callback.answer(str(exc), show_alert=True) return if callback.message: await callback.message.edit_text( format_budgets_list(items), reply_markup=budgets_keyboard(items), ) state_label = "включён" if status.budget.is_active else "выключен" await callback.answer(f"{status.budget.name}: {state_label}") @router.callback_query(F.data.startswith(BUDGET_DELETE_PREFIX)) async def cb_budget_delete( callback: CallbackQuery, budget_service: BudgetService, ) -> None: raw = (callback.data or "")[len(BUDGET_DELETE_PREFIX) :] try: budget_id = int(raw) except ValueError: await callback.answer("Некорректный id", show_alert=True) return try: current = await budget_service.get_status( callback.from_user.id, budget_id=budget_id, ) name = current.budget.name await budget_service.delete_budget(callback.from_user.id, budget_id) items = await budget_service.list_user_statuses(callback.from_user.id) except (ValueError, BudgetApiError) as exc: await callback.answer(str(exc), show_alert=True) return if callback.message: await callback.message.edit_text( format_budgets_list(items), reply_markup=budgets_keyboard(items) or None, ) await callback.answer(f"Удалён: {name}") @router.message(Command("spend")) @router.message(F.text == MENU_DATED) async def cmd_spend_dated(message: Message, state: FSMContext) -> None: await state.set_state(DatedExpense.waiting_date) await message.answer( "Трата за дату — одним сообщением или по шагам.\n\n" "Сразу: 12.09 250 кб\n" "Или только дата: 12.09\n" "Отмена: /cancel", reply_markup=ReplyKeyboardRemove(), ) # --- FSM: budget --- @router.message(BudgetSetup.waiting_name) async def budget_name(message: Message, state: FSMContext) -> None: raw = (message.text or "").strip() name = "Бюджет" if raw in {"", "-", "—"} else raw[:64] await state.update_data(name=name) await state.set_state(BudgetSetup.waiting_amount) await message.answer( f"Название: {name}.\n" "Сколько денег в этом бюджете? (например: 25000)" ) @router.message(BudgetSetup.waiting_amount) async def budget_amount(message: Message, state: FSMContext) -> None: try: amount = parse_amount(message.text or "") except ValueError as exc: await message.answer(f"{exc}\nПопробуй ещё раз, например: 25000") return await state.update_data(amount=amount) await state.set_state(BudgetSetup.waiting_end_date) await message.answer( "До какой даты нужно протянуть?\n" "Форматы: 25.09 · 25.09.2026 · 2026-09-25" ) @router.message(BudgetSetup.waiting_end_date) async def budget_end_date( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: try: end_date = parse_end_date(message.text or "") except ValueError as exc: await message.answer(f"{exc}\nПример: 25.09.2026") return data = await state.get_data() amount = float(data["amount"]) name = str(data.get("name") or "Бюджет") try: status = await budget_service.create_budget( user_id=message.from_user.id, total_amount=amount, end_date=end_date, name=name, ) except ValueError as exc: await message.answer(str(exc)) return except BudgetApiError as exc: await message.answer(f"API недоступен: {exc}") return await state.clear() await message.answer( f"Бюджет «{name}» создан: {format_money(amount)} до {format_date(end_date)}.\n\n" f"{format_status(status)}", reply_markup=main_keyboard(), ) # --- FSM: dated expense --- @router.message(DatedExpense.waiting_date) async def dated_expense_date( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: text = (message.text or "").strip() if not text: await message.answer("Введи дату, например: 12.09") return # One-shot: "12.09 250 кб" / "за 12.09 250 кб" try: amount, note, spent_at = parse_expense_message(text) except ValueError: amount, note, spent_at = None, None, None if amount is not None and spent_at is not None: await _save_expense( message, budget_service, amount=amount, note=note, spent_at=spent_at, state=state, ) return # Date only, or "12.09 кб" (date + note without amount) tokens = text.split() try: spent_at = parse_spent_date(tokens[0].rstrip(":")) except ValueError: await message.answer( "Не понял дату.\n" "Примеры: 12.09 или 12.09 250 кб\n" "Отмена: /cancel" ) return if spent_at > date.today(): await message.answer("Нельзя добавить трату на будущую дату. Введи другую:") return rest = " ".join(tokens[1:]).strip() if rest: try: amount, note, nested = parse_expense_message(rest) except ValueError: # "12.09 кб" → дата есть, суммы нет: запомним заметку и спросим сумму await state.update_data(spent_at=spent_at.isoformat(), note_hint=rest) await state.set_state(DatedExpense.waiting_expense) await message.answer( f"Дата: {format_date(spent_at)}, заметка: {rest}.\n" "Теперь сумму, например: 250" ) return if nested is not None: spent_at = nested await _save_expense( message, budget_service, amount=amount, note=note, spent_at=spent_at, state=state, ) return await state.update_data(spent_at=spent_at.isoformat(), note_hint=None) await state.set_state(DatedExpense.waiting_expense) await message.answer( f"Дата: {format_date(spent_at)}.\n" "Теперь сумма (и комментарий): 250 или 250 кб" ) @router.message(DatedExpense.waiting_expense) async def dated_expense_amount( message: Message, state: FSMContext, budget_service: BudgetService, ) -> None: data = await state.get_data() spent_at = date.fromisoformat(data["spent_at"]) note_hint = data.get("note_hint") try: amount, note, nested_date = parse_expense_message(message.text or "") except ValueError as exc: await message.answer(f"{exc}") return if nested_date is not None: spent_at = nested_date if note is None and note_hint: note = note_hint await _save_expense( message, budget_service, amount=amount, note=note, spent_at=spent_at, state=state, ) # --- Free-text expense (no active dialog) --- @router.message(StateFilter(None), F.text) async def add_expense_from_text( message: Message, budget_service: BudgetService, ) -> None: text = (message.text or "").strip() if text.startswith("/"): return try: amount, note, spent_at = parse_expense_message(text) except ValueError: await message.answer( "Не понял. Примеры:\n" "• 250 / 250 кофе / кофе 250\n" "• за 12.09 250 кофе\n" "• /spend — диалог за дату" ) return await _save_expense( message, budget_service, amount=amount, note=note, spent_at=spent_at, )