Files
please-pay-me/bot/handlers/budget.py
T
2026-09-21 04:06:43 +03:00

782 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)