import hashlib from datetime import datetime, timedelta from typing import Any from aiogram import F, Router, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder from aiohttp import web from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from config import ( FREEKASSA_SECRET1, FREEKASSA_SECRET2, FREEKASSA_SHOP_ID, ) from database import ( add_payment, add_user, async_session_maker, check_user_exists, get_key_count, get_temporary_data, update_balance, ) from database.models import Payment from handlers.buttons import BACK, PAY_2 from handlers.payments.utils import send_payment_success_notification from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS from handlers.utils import edit_or_send_message from logger import logger router = Router() class ReplenishBalanceState(StatesGroup): choosing_amount_freekassa = State() waiting_for_payment_confirmation_freekassa = State() def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str: signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}" signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() logger.debug(f"Generated signature for order {order_id}: {signature}") return signature def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str: signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency) payment_url = "https://pay.fk.money/" params = { "m": FREEKASSA_SHOP_ID, "oa": amount, "currency": currency, "o": order_id, "s": signature, "us_tg_id": tg_id, } query_string = "&".join([f"{key}={value}" for key, value in params.items()]) full_url = f"{payment_url}?{query_string}" logger.info(f"Generated Freekassa payment link: {full_url}") return full_url @router.callback_query(F.data == "pay_freekassa") async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any): tg_id = callback_query.message.chat.id logger.info(f"User {tg_id} initiated Freekassa payment.") builder = InlineKeyboardBuilder() for i in range(0, len(PAYMENT_OPTIONS), 2): if i + 1 < len(PAYMENT_OPTIONS): builder.row( InlineKeyboardButton( text=PAYMENT_OPTIONS[i]["text"], callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", ), InlineKeyboardButton( text=PAYMENT_OPTIONS[i + 1]["text"], callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}", ), ) else: builder.row( InlineKeyboardButton( text=PAYMENT_OPTIONS[i]["text"], callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", ) ) builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) key_count = await get_key_count(session, tg_id) if key_count == 0: exists = await check_user_exists(session, tg_id) if not exists: from_user = callback_query.from_user await add_user( tg_id=from_user.id, username=from_user.username, first_name=from_user.first_name, last_name=from_user.last_name, language_code=from_user.language_code, is_bot=from_user.is_bot, session=session, ) logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.") await callback_query.message.delete() new_message = await callback_query.message.answer( text="Выберите сумму пополнения:", reply_markup=builder.as_markup(), ) await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id) await state.set_state(ReplenishBalanceState.choosing_amount_freekassa) logger.info(f"Displayed amount selection for user {tg_id}.") @router.callback_query(F.data.startswith("freekassa_amount|")) async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): logger.info(f"Получены данные callback_data: {callback_query.data}") data = callback_query.data.split("|") if len(data) != 3 or data[1] != "amount": logger.error("Ошибка: callback_data не соответствует формату.") await edit_or_send_message( target_message=callback_query.message, text="Ошибка: данные повреждены.", reply_markup=types.InlineKeyboardMarkup(), force_text=True, ) return amount_str = data[2] try: amount = float(amount_str) if amount <= 0: raise ValueError("Сумма должна быть положительным числом.") except ValueError as e: logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}") await edit_or_send_message( target_message=callback_query.message, text="Некорректная сумма.", reply_markup=types.InlineKeyboardMarkup(), force_text=True, ) return await state.update_data(amount=amount) logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.") tg_id = callback_query.message.chat.id order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" payment_url = generate_payment_link(amount, order_id, tg_id) logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}") confirm_keyboard = InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text=PAY_2, url=payment_url)], [InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")], ] ) await edit_or_send_message( target_message=callback_query.message, text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount), reply_markup=confirm_keyboard, force_text=True, ) logger.info(f"Payment link sent to user {callback_query.message.chat.id}.") def verify_signature(params: dict) -> bool: try: merchant_id = params.get("MERCHANT_ID", "") amount = params.get("AMOUNT", "") merchant_order_id = params.get("MERCHANT_ORDER_ID", "") sign = params.get("SIGN", "") signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}" expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest() logger.debug(f"Signature verification: expected={expected_signature}, received={sign}") return expected_signature == sign except Exception as e: logger.error(f"Error verifying signature: {e}") return False async def freekassa_webhook(request: web.Request): try: params = dict(request.query) logger.info(f"Received Freekassa webhook: {params}") merchant_id = params.get("MERCHANT_ID") amount = params.get("AMOUNT") merchant_order_id = params.get("MERCHANT_ORDER_ID") sign = params.get("SIGN") tg_id = params.get("us_tg_id") if not all([merchant_id, amount, merchant_order_id, sign]): logger.error("Missing required parameters in webhook") return web.Response(status=400, text="Missing required parameters") if not verify_signature(params): logger.error("Invalid signature in webhook") return web.Response(status=400, text="Invalid signature") if str(merchant_id) != str(FREEKASSA_SHOP_ID): logger.error(f"Invalid merchant_id: {merchant_id}") return web.Response(status=400, text="Invalid merchant_id") try: amount_float = float(amount) if tg_id: tg_id_int = int(tg_id) else: order_parts = merchant_order_id.split("_") if len(order_parts) >= 3 and order_parts[0] == "order": tg_id_int = int(order_parts[1]) else: logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}") return web.Response(status=400, text="Cannot identify user") except (ValueError, TypeError) as e: logger.error(f"Error parsing parameters: {e}") return web.Response(status=400, text="Invalid parameter format") async with async_session_maker() as session: recent_time = datetime.utcnow() - timedelta(minutes=1) result = await session.execute( select(Payment).where( and_( Payment.tg_id == tg_id_int, Payment.amount == amount_float, Payment.status == "success", Payment.created_at >= recent_time, ) ) ) duplicate = result.scalar_one_or_none() if duplicate: logger.warning( f"[Freekassa] Повторный webhook. Платёж уже обработан: tg_id={tg_id_int}, amount={amount_float}" ) return web.Response(text="YES") await update_balance(session, tg_id_int, amount_float) await send_payment_success_notification(tg_id_int, amount_float, session) await add_payment(session, tg_id_int, amount_float, "freekassa") logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}") return web.Response(text="YES") except Exception as e: logger.error(f"Error processing Freekassa webhook: {e}") return web.Response(status=500, text="Internal server error") @router.callback_query(F.data == "enter_custom_amount_freekassa") async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): tg_id = callback_query.message.chat.id logger.info(f"User {tg_id} chose to enter a custom amount.") builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) await edit_or_send_message( target_message=callback_query.message, text=ENTER_SUM, reply_markup=builder.as_markup(), force_text=True, ) await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) @router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa) async def handle_custom_amount_input( message: types.Message | types.CallbackQuery, state: FSMContext = None, session: AsyncSession = None, ): if isinstance(message, types.CallbackQuery): tg_id = message.message.chat.id target_message = message.message else: tg_id = message.chat.id target_message = message logger.info(f"User {tg_id} initiated payment through Freekassa") try: user_data = await get_temporary_data(session, tg_id) if not user_data: await edit_or_send_message( target_message=target_message, text="Данные для оплаты не найдены. Попробуйте снова.", reply_markup=types.InlineKeyboardMarkup(), ) return state_type = user_data["state"] amount = user_data["data"].get("required_amount", 0) if amount <= 0: await edit_or_send_message( target_message=target_message, text="Недостаточная сумма для пополнения.", reply_markup=types.InlineKeyboardMarkup(), ) return order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}" payment_url = generate_payment_link(amount, order_id, tg_id) logger.info(f"Generated payment link for user {tg_id}: {payment_url}") builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url)) builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")) if state_type == "waiting_for_payment": message_text = ( f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" ) elif state_type == "waiting_for_renewal_payment": message_text = ( f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" ) else: await edit_or_send_message( target_message=target_message, text="Некорректное состояние данных. Попробуйте снова.", reply_markup=types.InlineKeyboardMarkup(), ) return await edit_or_send_message( target_message=target_message, text=message_text, reply_markup=builder.as_markup(), ) if isinstance(state, FSMContext): await state.clear() except Exception as e: logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}") await edit_or_send_message( target_message=target_message, text="Произошла ошибка при создании платежа. Попробуйте позже.", reply_markup=types.InlineKeyboardMarkup(), )