@@ -0,0 +1,781 @@
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 ,
)