diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index 2444ab2..ecf8b9f 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -7,6 +7,7 @@ from . import user_management from . import statistics from . import sync_admin from . import logs_admin +from . import payments admin_router_aggregate = Router(name="admin_features_router") @@ -17,5 +18,6 @@ admin_router_aggregate.include_router(user_management.router) admin_router_aggregate.include_router(statistics.router) admin_router_aggregate.include_router(sync_admin.router) admin_router_aggregate.include_router(logs_admin.router) +admin_router_aggregate.include_router(payments.router) __all__ = ("admin_router_aggregate", ) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index a43c57d..8204404 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -123,6 +123,10 @@ async def admin_panel_actions_callback_handler( await callback.answer(_("admin_sync_initiated_from_panel")) elif action == "queue_status": await show_queue_status_handler(callback, i18n_data) + elif action == "view_payments": + from . import payments as admin_payments_handlers + await admin_payments_handlers.view_payments_handler( + callback, i18n_data, settings, session) elif action == "main": try: await callback.message.edit_text( diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py new file mode 100644 index 0000000..2b4a95e --- /dev/null +++ b/bot/handlers/admin/payments.py @@ -0,0 +1,245 @@ +import logging +import csv +import io +from aiogram import Router, F, types +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import payment_dal +from db.models import Payment +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="admin_payments_router") + + +async def get_payments_with_pagination(session: AsyncSession, page: int = 0, + page_size: int = 10) -> tuple[List[Payment], int]: + """Get payments with pagination and total count.""" + offset = page * page_size + + # Get total count + total_count = await payment_dal.get_payments_count(session) + + # Get payments for current page + payments = await payment_dal.get_recent_payment_logs_with_user( + session, limit=page_size, offset=offset + ) + + return payments, total_count + + +def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: + """Format single payment info as text.""" + _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) + + status_emoji = "✅" if payment.status == 'succeeded' else ( + "⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌" + ) + + user_info = f"User {payment.user_id}" + if payment.user and payment.user.username: + user_info += f" (@{payment.user.username})" + elif payment.user and payment.user.first_name: + user_info += f" ({payment.user.first_name})" + + payment_date = payment.created_at.strftime('%Y-%m-%d %H:%M') if payment.created_at else "N/A" + + provider_text = { + 'yookassa': 'YooKassa', + 'tribute': 'Tribute', + 'telegram_stars': 'Telegram Stars', + 'cryptopay': 'CryptoPay' + }.get(payment.provider, payment.provider or 'Unknown') + + return ( + f"{status_emoji} {payment.amount} {payment.currency}\n" + f"👤 {user_info}\n" + f"💳 {provider_text}\n" + f"📅 {payment_date}\n" + f"📋 {payment.status}\n" + f"📝 {payment.description or 'N/A'}" + ) + + +async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession, page: int = 0): + """Display paginated list of all payments.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + page_size = 5 # Показываем по 5 платежей на странице + payments, total_count = await get_payments_with_pagination(session, page, page_size) + total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1 + + if not payments and page == 0: + await callback.message.edit_text( + _("admin_no_payments_found", default="Платежи не найдены."), + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + return + + # Format payments text + text_parts = [_("admin_payments_header", default="💰 Все платежи")] + text_parts.append(f"📊 Показано {len(payments)} из {total_count} платежей (стр. {page + 1}/{total_pages})\n") + + for i, payment in enumerate(payments, 1): + text_parts.append(f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang)}") + text_parts.append("") # Empty line between payments + + # Build keyboard with pagination and export + builder = InlineKeyboardBuilder() + + # Pagination buttons + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page-1}")) + + nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop")) + + if page < total_pages - 1: + nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page+1}")) + + if nav_buttons: + builder.row(*nav_buttons) + + # Export and refresh buttons + builder.row( + InlineKeyboardButton( + text=_("admin_export_payments_csv", default="📊 Экспорт CSV"), + callback_data="payments_export_csv" + ), + InlineKeyboardButton( + text=_("admin_refresh_payments", default="🔄 Обновить"), + callback_data=f"payments_page:{page}" + ) + ) + + # Back button + builder.row(InlineKeyboardButton( + text=_("back_to_admin_panel_button"), + callback_data="admin_section:stats_monitoring" + )) + + await callback.message.edit_text( + "\n".join(text_parts), + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("payments_page:")) +async def payments_pagination_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Handle pagination for payments list.""" + try: + page = int(callback.data.split(":")[1]) + await view_payments_handler(callback, i18n_data, settings, session, page) + except (ValueError, IndexError): + await callback.answer("Error processing pagination.", show_alert=True) + + +@router.callback_query(F.data == "payments_export_csv") +async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Export all successful payments to CSV file.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + # Get all successful payments + all_payments = await payment_dal.get_all_succeeded_payments_with_user(session) + + if not all_payments: + await callback.answer( + _("admin_no_payments_to_export", default="Нет платежей для экспорта."), + show_alert=True + ) + return + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + _("admin_csv_payment_id", default="ID"), + _("admin_csv_user_id", default="User ID"), + _("admin_csv_username", default="Username"), + _("admin_csv_first_name", default="First Name"), + _("admin_csv_amount", default="Amount"), + _("admin_csv_currency", default="Currency"), + _("admin_csv_provider", default="Provider"), + _("admin_csv_status", default="Status"), + _("admin_csv_description", default="Description"), + _("admin_csv_months", default="Months"), + _("admin_csv_created_at", default="Created At"), + _("admin_csv_provider_payment_id", default="Provider Payment ID") + ]) + + # Write payment data + for payment in all_payments: + writer.writerow([ + payment.payment_id, + payment.user_id, + payment.user.username if payment.user and payment.user.username else "", + payment.user.first_name if payment.user and payment.user.first_name else "", + payment.amount, + payment.currency, + payment.provider or "", + payment.status, + payment.description or "", + payment.subscription_duration_months or "", + payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "", + payment.provider_payment_id or "" + ]) + + # Prepare file + csv_content = output.getvalue().encode('utf-8-sig') # UTF-8 with BOM for Excel + output.close() + + # Generate filename with current date + current_time = datetime.now().strftime('%Y-%m-%d_%H-%M') + filename = f"payments_export_{current_time}.csv" + + # Send file + from aiogram.types import BufferedInputFile + file = BufferedInputFile(csv_content, filename=filename) + + await callback.message.reply_document( + document=file, + caption=_("admin_payments_export_success", + default="📊 Экспорт платежей завершен!\nВсего записей: {count}", + count=len(all_payments)) + ) + + await callback.answer( + _("admin_export_sent", default="Файл отправлен!"), + show_alert=False + ) + + except Exception as e: + logging.error(f"Failed to export payments CSV: {e}", exc_info=True) + await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "noop") +async def noop_handler(callback: types.CallbackQuery): + """Handle no-op callback (for pagination display).""" + await callback.answer() \ No newline at end of file diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index bf0d4e7..293100e 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -39,12 +39,14 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar builder.button(text=_(key="admin_stats_button"), callback_data="admin_action:stats") + builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"), + callback_data="admin_action:view_payments") builder.button(text=_(key="admin_view_logs_menu_button"), callback_data="admin_action:view_logs_menu") builder.button(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main") - builder.adjust(2, 1) + builder.adjust(2, 1, 1) return builder.as_markup() diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 5a93e6d..9bb2c12 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -90,6 +90,22 @@ async def get_recent_payment_logs_with_user(session: AsyncSession, return result.scalars().all() +async def get_payments_count(session: AsyncSession) -> int: + """Get total count of successful payments.""" + stmt = select(func.count(Payment.payment_id)).where(Payment.status == 'succeeded') + result = await session.execute(stmt) + return result.scalar() or 0 + + +async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]: + """Get all successful payments with user data for export.""" + stmt = (select(Payment).options(selectinload(Payment.user)) + .where(Payment.status == 'succeeded') + .order_by(Payment.created_at.desc())) + result = await session.execute(stmt) + return result.scalars().all() + + async def update_provider_payment_and_status( session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str) -> Optional[Payment]: diff --git a/locales/en.json b/locales/en.json index 43d5ca8..5712a5f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -122,6 +122,28 @@ "admin_stats_recent_payments_header": "Recent Payments:", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", "admin_stats_no_payments_found": "No payments found yet.", + + "admin_view_payments_button": "💰 Payments", + "admin_payments_header": "💰 All Payments", + "admin_no_payments_found": "No payments found.", + "admin_export_payments_csv": "📊 Export CSV", + "admin_refresh_payments": "🔄 Refresh", + "admin_no_payments_to_export": "No payments to export.", + "admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}", + "admin_export_sent": "File sent!", + + "admin_csv_payment_id": "ID", + "admin_csv_user_id": "User ID", + "admin_csv_username": "Username", + "admin_csv_first_name": "First Name", + "admin_csv_amount": "Amount", + "admin_csv_currency": "Currency", + "admin_csv_provider": "Provider", + "admin_csv_status": "Status", + "admin_csv_description": "Description", + "admin_csv_months": "Months", + "admin_csv_created_at": "Created At", + "admin_csv_provider_payment_id": "Provider Payment ID", "admin_stats_last_sync_header": "Last Panel Sync:", "admin_stats_sync_time": "Time", "admin_stats_sync_status": "Status", diff --git a/locales/ru.json b/locales/ru.json index 1cb6640..8197e9a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -122,6 +122,28 @@ "admin_stats_recent_payments_header": "Последние платежи:", "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", "admin_stats_no_payments_found": "Платежей пока нет.", + + "admin_view_payments_button": "💰 Платежи", + "admin_payments_header": "💰 Все платежи", + "admin_no_payments_found": "Платежи не найдены.", + "admin_export_payments_csv": "📊 Экспорт CSV", + "admin_refresh_payments": "🔄 Обновить", + "admin_no_payments_to_export": "Нет платежей для экспорта.", + "admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}", + "admin_export_sent": "Файл отправлен!", + + "admin_csv_payment_id": "ID", + "admin_csv_user_id": "User ID", + "admin_csv_username": "Логин", + "admin_csv_first_name": "Имя", + "admin_csv_amount": "Сумма", + "admin_csv_currency": "Валюта", + "admin_csv_provider": "Платежная система", + "admin_csv_status": "Статус", + "admin_csv_description": "Описание", + "admin_csv_months": "Месяцев", + "admin_csv_created_at": "Дата создания", + "admin_csv_provider_payment_id": "ID платежа в системе", "admin_stats_last_sync_header": "Последняя синхронизация с панелью:", "admin_stats_sync_time": "Время", "admin_stats_sync_status": "Статус",