782 lines
24 KiB
Python
782 lines
24 KiB
Python
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,
|
||
)
|