From 9f353e8b5ce1a0efc9533473ae9a00e02b0eb69e Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:00:19 +1000 Subject: [PATCH] =?UTF-8?q?FIX=20"=D0=9A=D0=BE=D0=BD=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20=D0=BA=D0=B0=D1=81=D1=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX "Консистентный дизайн касс" Heleket и Kassai - Оптимизация касс по PEP8 - Оптимизация функций KassaI --- handlers/payments/heleket/handlers.py | 113 +++++++--- handlers/payments/heleket/service.py | 47 ++-- handlers/payments/heleket/webhook.py | 175 ++++++++++----- handlers/payments/kassai/handlers.py | 297 +++++++++++++++----------- handlers/payments/kassai/service.py | 79 +++++-- handlers/payments/kassai/webhook.py | 102 ++++++--- 6 files changed, 537 insertions(+), 276 deletions(-) diff --git a/handlers/payments/heleket/handlers.py b/handlers/payments/heleket/handlers.py index 29c1e2fd..665248cd 100644 --- a/handlers/payments/heleket/handlers.py +++ b/handlers/payments/heleket/handlers.py @@ -1,25 +1,38 @@ from aiogram import F, Router, types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from handlers.buttons import PAY_2, MAIN_MENU +from database import get_temporary_data +from database.models import User +from handlers.buttons import MAIN_MENU, PAY_2 +from handlers.payments.currency_rates import format_for_user from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.utils import edit_or_send_message -from handlers.payments.currency_rates import format_for_user -from database import get_temporary_data from logger import logger -from .service import HELEKET_PAYMENT_METHODS, generate_heleket_payment_link, process_callback_pay_heleket -from .service import router as service_router +from .service import ( + HELEKET_PAYMENT_METHODS, + generate_heleket_payment_link, + process_callback_pay_heleket, + router as service_router, +) router = Router(name="heleket_router") router.include_router(service_router) @router.callback_query(F.data == "pay_heleket_crypto") -async def handle_pay_heleket_crypto(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): - await process_callback_pay_heleket(callback_query, state, session, method_name="crypto") +async def handle_pay_heleket_crypto( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, +): + """Обработчик оплаты через Heleket криптовалютой.""" + await process_callback_pay_heleket( + callback_query, state, session, method_name="crypto" + ) async def handle_custom_amount_input_heleket( @@ -28,63 +41,103 @@ async def handle_custom_amount_input_heleket( pay_button_text: str = PAY_2, main_menu_text: str = MAIN_MENU, ): - """ - Функция быстрого потока для Heleket - принимает недостающую сумму и формирует платеж. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. + """Функция быстрого потока для Heleket. + + Принимает недостающую сумму и формирует платеж. + Работает с временными данными из fast_payment_flow для + создания/продления/подарка. """ message = event.message from_user = event.from_user tg_id = from_user.id - + temp_data = await get_temporary_data(session, tg_id) - if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]: - await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.") + valid_states = [ + "waiting_for_payment", + "waiting_for_renewal_payment", + "waiting_for_gift_payment", + ] + if not temp_data or temp_data["state"] not in valid_states: + await edit_or_send_message( + target_message=message, + text="❌ Не удалось получить данные для оплаты.", + ) return - + amount = int(temp_data["data"].get("required_amount", 0)) if amount <= 0: - await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") + await edit_or_send_message( + target_message=message, + text="❌ Не удалось определить сумму оплаты.", + ) return - + if amount < 10: - await edit_or_send_message(target_message=message, text="❌ Минимальная сумма для оплаты криптовалютой — 10₽ (≈0.1$).") + await edit_or_send_message( + target_message=message, + text="❌ Минимальная сумма для оплаты криптовалютой — 10₽ (≈0.1$).", + ) return - + enabled_methods = [m for m in HELEKET_PAYMENT_METHODS if m["enable"]] if not enabled_methods: - await edit_or_send_message(target_message=message, text="❌ Способ оплаты Heleket временно недоступен.") + await edit_or_send_message( + target_message=message, + text="❌ Способ оплаты Heleket временно недоступен.", + ) return method = enabled_methods[0] try: - payment_url = await generate_heleket_payment_link(amount, tg_id, method) - + payment_url = await generate_heleket_payment_link( + amount, tg_id, method + ) + if not payment_url or payment_url == "https://heleket.com/": await edit_or_send_message( target_message=message, - text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", + text=( + "❌ Произошла ошибка при создании платежа. " + "Попробуйте позже или выберите другой способ оплаты." + ), ) return markup = InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + [ + InlineKeyboardButton( + text=main_menu_text, callback_data="profile" + ) + ], ] ) - from database.models import User - from sqlalchemy import select - result = await session.execute(select(User.language_code).where(User.tg_id == tg_id)) + result = await session.execute( + select(User.language_code).where(User.tg_id == tg_id) + ) language_code = result.scalar_one_or_none() - amount_text = await format_for_user(session, tg_id, float(amount), language_code) + amount_text = await format_for_user( + session, + tg_id, + float(amount), + language_code, + force_currency="USD", + ) text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text) - await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup) + await edit_or_send_message( + target_message=message, text=text_out, reply_markup=markup + ) except Exception as e: - logger.error(f"Ошибка при создании платежа Heleket для пользователя {tg_id}: {e}") + logger.error( + f"Ошибка при создании платежа Heleket " + f"для пользователя {tg_id}: {e}" + ) await edit_or_send_message( target_message=message, text="Произошла ошибка при создании платежа. Попробуйте позже.", reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), ) + diff --git a/handlers/payments/heleket/service.py b/handlers/payments/heleket/service.py index 8bb3c95d..14b0efa8 100644 --- a/handlers/payments/heleket/service.py +++ b/handlers/payments/heleket/service.py @@ -2,7 +2,7 @@ import base64 import hashlib import json import time -from decimal import Decimal, ROUND_HALF_UP +from decimal import ROUND_HALF_UP, Decimal import aiohttp from aiogram import F, Router, types @@ -10,8 +10,8 @@ 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 sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from handlers.payments.currency_rates import format_for_user from config import ( HELEKET_API_KEY, @@ -21,14 +21,28 @@ from config import ( HELEKET_SUCCESS_URL, PROVIDERS_ENABLED, ) -from handlers.payments.providers import get_providers -from handlers.payments.currency_rates import get_rub_rate -from handlers.buttons import BACK, HELEKET, PAY_2 -from handlers.texts import ENTER_SUM, HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE -from handlers.payments.keyboards import build_amounts_keyboard, payment_options_for_user, parse_amount_from_callback, pay_keyboard -from handlers.utils import edit_or_send_message from database import add_payment, async_session_maker from database.models import User +from handlers.buttons import BACK, HELEKET, PAY_2 +from handlers.payments.currency_rates import ( + format_for_user, + get_rub_rate, + pick_currency, + to_rub, +) +from handlers.payments.keyboards import ( + build_amounts_keyboard, + parse_amount_from_callback, + pay_keyboard, + payment_options_for_user, +) +from handlers.payments.providers import get_providers +from handlers.texts import ( + ENTER_SUM, + HELEKET_CRYPTO_DESCRIPTION, + HELEKET_PAYMENT_MESSAGE, +) +from handlers.utils import edit_or_send_message from logger import logger @@ -37,7 +51,6 @@ router = Router() async def get_user_language(session: AsyncSession, tg_id: int) -> str | None: """Получает язык пользователя из базы данных""" - from sqlalchemy import select result = await session.execute( select(User.language_code).where(User.tg_id == tg_id) ) @@ -91,7 +104,9 @@ async def process_callback_pay_heleket( return language_code = await get_user_language(session, tg_id) - opts = await payment_options_for_user(session, tg_id, language_code) + opts = await payment_options_for_user( + session, tg_id, language_code, force_currency="USD" + ) builder = build_amounts_keyboard( prefix=f"heleket_{method_name}", pattern="{prefix}_amount|{price}", @@ -159,7 +174,9 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F tg_id = callback_query.from_user.id language_code = await get_user_language(session, tg_id) - opts = await payment_options_for_user(session, tg_id, language_code) + opts = await payment_options_for_user( + session, tg_id, language_code, force_currency="USD" + ) builder = build_amounts_keyboard( prefix=f"heleket_{method_name}", pattern="{prefix}_amount|{price}", @@ -186,7 +203,6 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto")) - from ..currency_rates import pick_currency language_code = await get_user_language(session, callback_query.from_user.id) currency = pick_currency(language_code) @@ -215,7 +231,6 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, ) return - from ..currency_rates import pick_currency language_code = await get_user_language(session, message.from_user.id) currency = pick_currency(language_code) @@ -244,7 +259,6 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, ) return - from ..currency_rates import to_rub if currency == "RUB": amount_rub = user_amount else: @@ -265,9 +279,8 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance") - from ..currency_rates import format_for_user tg_id = message.from_user.id - amount_text = await format_for_user(session, tg_id, float(amount_rub), language_code) + amount_text = await format_for_user(session, tg_id, float(amount_rub), language_code, force_currency="USD") await edit_or_send_message( target_message=message, @@ -328,7 +341,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F tg_id = callback_query.from_user.id language_code = await get_user_language(session, tg_id) - amount_text = await format_for_user(session, tg_id, float(amount), language_code) + amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="USD") await edit_or_send_message( target_message=callback_query.message, diff --git a/handlers/payments/heleket/webhook.py b/handlers/payments/heleket/webhook.py index e1c90755..059de66f 100644 --- a/handlers/payments/heleket/webhook.py +++ b/handlers/payments/heleket/webhook.py @@ -1,32 +1,58 @@ import base64 import hashlib import json + from aiohttp import web -from logger import logger + from config import HELEKET_API_KEY -from database import async_session_maker, update_payment_status, update_balance, add_payment, get_payment_by_payment_id +from database import ( + add_payment, + async_session_maker, + get_payment_by_payment_id, + update_balance, + update_payment_status, +) from handlers.payments.utils import send_payment_success_notification +from logger import logger def verify_heleket_signature(data: dict) -> bool: + """Проверяет подпись webhook от Heleket. + + Args: + data: Данные от webhook + + Returns: + True если подпись валидна, иначе False + """ try: - received_signature = data.get('sign') + received_signature = data.get("sign") if not received_signature: logger.error("Heleket webhook: отсутствует подпись") return False - + data_without_sign = data.copy() - del data_without_sign['sign'] - - json_data = json.dumps(data_without_sign, ensure_ascii=False, separators=(',', ':')) - json_data = json_data.replace('/', '\\/') - base64_data = base64.b64encode(json_data.encode('utf-8')).decode('utf-8') + del data_without_sign["sign"] + + json_data = json.dumps( + data_without_sign, ensure_ascii=False, separators=(",", ":") + ) + json_data = json_data.replace("/", "\\/") + base64_data = base64.b64encode(json_data.encode("utf-8")).decode( + "utf-8" + ) sign_string = base64_data + HELEKET_API_KEY - calculated_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest() + calculated_signature = hashlib.md5( + sign_string.encode("utf-8") + ).hexdigest() is_valid = calculated_signature.lower() == received_signature.lower() - + if not is_valid: - logger.error(f"Heleket webhook: неверная подпись. Ожидалось: {calculated_signature}, получено: {received_signature}") + logger.error( + f"Heleket webhook: неверная подпись. " + f"Ожидалось: {calculated_signature}, " + f"получено: {received_signature}" + ) logger.error(f"Heleket webhook: строка для подписи: {sign_string}") else: logger.info("Heleket webhook: подпись успешно проверена") @@ -37,59 +63,86 @@ def verify_heleket_signature(data: dict) -> bool: async def process_heleket_webhook(data: dict) -> bool: + """Обрабатывает webhook от Heleket. + + Args: + data: Данные от webhook + + Returns: + True если обработка успешна, иначе False + """ try: logger.info(f"Processing Heleket webhook: {data}") - - webhook_type = data.get('type') - uuid = data.get('uuid') - order_id = data.get('order_id') - status = data.get('status') - amount = data.get('amount') - payment_amount = data.get('payment_amount') - merchant_amount = data.get('merchant_amount') - currency = data.get('currency') - payer_currency = data.get('payer_currency') - additional_data = data.get('additional_data') - is_final = data.get('is_final', False) - - logger.info(f"Heleket webhook - Type: {webhook_type}, UUID: {uuid}, Order: {order_id}, Status: {status}") - if webhook_type != 'payment': + + webhook_type = data.get("type") + order_id = data.get("order_id") + status = data.get("status") + payment_amount = data.get("payment_amount") + merchant_amount = data.get("merchant_amount") + payer_currency = data.get("payer_currency") + additional_data = data.get("additional_data") + + logger.info( + f"Heleket webhook - Type: {webhook_type}, " + f"Order: {order_id}, Status: {status}" + ) + if webhook_type != "payment": logger.warning(f"Heleket webhook: неизвестный тип {webhook_type}") return False - if status in ['paid', 'paid_over']: - logger.info(f"Heleket: успешный платёж {order_id} на сумму {payment_amount} {payer_currency}") + if status in ["paid", "paid_over"]: + logger.info( + f"Heleket: успешный платёж {order_id} на сумму " + f"{payment_amount} {payer_currency}" + ) tg_id = None rub_amount = None if additional_data: try: - for part in additional_data.split(','): - if part.startswith('tg_id:'): - tg_id = int(part.split(':')[1]) - elif part.startswith('rub_amount:'): - rub_amount = float(part.split(':')[1]) + for part in additional_data.split(","): + if part.startswith("tg_id:"): + tg_id = int(part.split(":")[1]) + elif part.startswith("rub_amount:"): + rub_amount = float(part.split(":")[1]) except Exception as e: logger.error(f"Ошибка парсинга additional_data: {e}") - if not tg_id and '_' in order_id: + if not tg_id and "_" in order_id: try: - tg_id = int(order_id.split('_')[1]) + tg_id = int(order_id.split("_")[1]) except Exception as e: - logger.error(f"Ошибка извлечения tg_id из order_id: {e}") + logger.error( + f"Ошибка извлечения tg_id из order_id: {e}" + ) if not tg_id: - logger.error(f"Не удалось извлечь tg_id из Heleket webhook: {data}") + logger.error( + f"Не удалось извлечь tg_id из Heleket webhook: {data}" + ) return False - balance_amount = rub_amount if rub_amount else float(merchant_amount) + balance_amount = ( + rub_amount if rub_amount else float(merchant_amount) + ) async with async_session_maker() as session: payment = await get_payment_by_payment_id(session, order_id) if payment: if payment.get("status") == "success": - logger.info(f"Heleket: платёж {order_id} уже обработан") + logger.info( + f"Heleket: платёж {order_id} уже обработан" + ) return True - ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success") + ok = await update_payment_status( + session=session, + internal_id=int(payment["id"]), + new_status="success", + ) if not ok: - logger.error(f"Heleket: не удалось обновить статус платежа {order_id}") + logger.error( + f"Heleket: не удалось обновить статус " + f"платежа {order_id}" + ) return False await update_balance(session, tg_id, balance_amount) - await send_payment_success_notification(tg_id, balance_amount, session) + await send_payment_success_notification( + tg_id, balance_amount, session + ) await session.commit() else: await add_payment( @@ -103,37 +156,51 @@ async def process_heleket_webhook(data: dict) -> bool: metadata=None, ) await update_balance(session, tg_id, balance_amount) - await send_payment_success_notification(tg_id, balance_amount, session) + await send_payment_success_notification( + tg_id, balance_amount, session + ) await session.commit() - logger.info(f"Heleket: платёж {order_id} для пользователя {tg_id} успешно обработан, баланс пополнен на {balance_amount} RUB") + logger.info( + f"Heleket: платёж {order_id} для пользователя {tg_id} " + f"успешно обработан, баланс пополнен на {balance_amount} RUB" + ) return True - elif status in ['fail', 'wrong_amount', 'cancel', 'system_fail']: - logger.warning(f"Heleket: неудачный платёж {order_id}, статус: {status}") - + elif status in ["fail", "wrong_amount", "cancel", "system_fail"]: + logger.warning( + f"Heleket: неудачный платёж {order_id}, статус: {status}" + ) + async with async_session_maker() as session: payment = await get_payment_by_payment_id(session, order_id) if payment: - await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="failed") + await update_payment_status( + session=session, + internal_id=int(payment["id"]), + new_status="failed", + ) await session.commit() return True else: - logger.info(f"Heleket: промежуточный статус {status} для платежа {order_id}") - return True + logger.info( + f"Heleket: промежуточный статус {status} " + f"для платежа {order_id}" + ) + return True except Exception as e: logger.error(f"Ошибка обработки Heleket webhook: {e}") return False async def heleket_webhook(request: web.Request): - """Обработчик вебхука Heleket для aiohttp""" + """Обработчик webhook от Heleket для aiohttp.""" try: data = await request.json() logger.info(f"Heleket webhook received from {request.remote}") - + if not verify_heleket_signature(data): logger.error("Heleket webhook: неверная подпись") return web.Response(status=400, text="Invalid signature") - + success = await process_heleket_webhook(data) if success: return web.Response(status=200, text="OK") diff --git a/handlers/payments/kassai/handlers.py b/handlers/payments/kassai/handlers.py index c4759a55..adf79274 100644 --- a/handlers/payments/kassai/handlers.py +++ b/handlers/payments/kassai/handlers.py @@ -1,30 +1,182 @@ from aiogram import F, Router, types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from handlers.buttons import PAY_2, MAIN_MENU +from database import get_temporary_data +from database.models import User +from handlers.buttons import MAIN_MENU, PAY_2 +from handlers.payments.currency_rates import format_for_user from handlers.texts import DEFAULT_PAYMENT_MESSAGE from handlers.utils import edit_or_send_message -from handlers.payments.currency_rates import format_for_user -from database import get_temporary_data from logger import logger -from .service import KASSAI_PAYMENT_METHODS, generate_kassai_payment_link, process_callback_pay_kassai -from .service import router as service_router +from .service import ( + KASSAI_PAYMENT_METHODS, + generate_kassai_payment_link, + process_callback_pay_kassai, + router as service_router, +) router = Router(name="kassai_router") router.include_router(service_router) @router.callback_query(F.data == "pay_kassai_cards") -async def handle_pay_kassai_cards(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): - await process_callback_pay_kassai(callback_query, state, session, method_name="cards") +async def handle_pay_kassai_cards( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, +): + """Обработчик оплаты через KassaI картами.""" + await process_callback_pay_kassai( + callback_query, state, session, method_name="cards" + ) @router.callback_query(F.data == "pay_kassai_sbp") -async def handle_pay_kassai_sbp(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): - await process_callback_pay_kassai(callback_query, state, session, method_name="sbp") +async def handle_pay_kassai_sbp( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, +): + """Обработчик оплаты через KassaI СБП.""" + await process_callback_pay_kassai( + callback_query, state, session, method_name="sbp" + ) + + +async def _handle_custom_amount_input_kassai( + event, + session: AsyncSession, + method_name: str, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + """Универсальная функция быстрого потока для KassaI. + + Принимает недостающую сумму и формирует платеж. + Работает с временными данными из fast_payment_flow для + создания/продления/подарка. + + Args: + event: Событие от aiogram + session: Сессия БД + method_name: "cards" или "sbp" + pay_button_text: Текст кнопки оплаты + main_menu_text: Текст кнопки главного меню + """ + message = event.message + from_user = event.from_user + tg_id = from_user.id + + temp_data = await get_temporary_data(session, tg_id) + valid_states = [ + "waiting_for_payment", + "waiting_for_renewal_payment", + "waiting_for_gift_payment", + ] + if not temp_data or temp_data["state"] not in valid_states: + await edit_or_send_message( + target_message=message, + text="❌ Не удалось получить данные для оплаты.", + ) + return + + amount = int(temp_data["data"].get("required_amount", 0)) + if amount <= 0: + await edit_or_send_message( + target_message=message, + text="❌ Не удалось определить сумму оплаты.", + ) + return + + min_amounts = {"cards": 50, "sbp": 10} + method_labels = {"cards": "картой", "sbp": "через СБП"} + min_amount = min_amounts.get(method_name, 10) + + if amount < min_amount: + method_label = method_labels.get(method_name, "") + error_msg = ( + f"❌ Минимальная сумма для оплаты {method_label} — " + f"{min_amount}₽." + ) + await edit_or_send_message( + target_message=message, + text=error_msg, + ) + return + + method = next( + ( + m + for m in KASSAI_PAYMENT_METHODS + if m["name"] == method_name and m["enable"] + ), + None, + ) + if not method: + method_label = "картами" if method_name == "cards" else "через СБП" + await edit_or_send_message( + target_message=message, + text=f"❌ Оплата {method_label} KassaAI временно недоступна.", + ) + return + + try: + payment_url = await generate_kassai_payment_link( + amount, tg_id, method + ) + + if not payment_url or payment_url == "https://fk.life/": + await edit_or_send_message( + target_message=message, + text=( + "❌ Произошла ошибка при создании платежа. " + "Попробуйте позже или выберите другой способ оплаты." + ), + ) + return + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [ + InlineKeyboardButton( + text=main_menu_text, callback_data="profile" + ) + ], + ] + ) + + result = await session.execute( + select(User.language_code).where(User.tg_id == tg_id) + ) + language_code = result.scalar_one_or_none() + amount_text = await format_for_user( + session, + tg_id, + float(amount), + language_code, + force_currency="RUB", + ) + text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text) + + await edit_or_send_message( + target_message=message, text=text_out, reply_markup=markup + ) + except Exception as e: + method_label = "Cards" if method_name == "cards" else "SBP" + logger.error( + f"Ошибка при создании платежа KassaAI {method_label} " + f"для пользователя {tg_id}: {e}" + ) + await edit_or_send_message( + target_message=message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) async def handle_custom_amount_input_kassai_cards( @@ -33,65 +185,10 @@ async def handle_custom_amount_input_kassai_cards( pay_button_text: str = PAY_2, main_menu_text: str = MAIN_MENU, ): - """ - Функция быстрого потока для KassaI Cards - принимает недостающую сумму и формирует платеж картами. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. - """ - message = event.message - from_user = event.from_user - tg_id = from_user.id - - temp_data = await get_temporary_data(session, tg_id) - if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]: - await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.") - return - - amount = int(temp_data["data"].get("required_amount", 0)) - if amount <= 0: - await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") - return - - if amount < 50: - await edit_or_send_message(target_message=message, text="❌ Минимальная сумма для оплаты картой — 50₽.") - return - - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "cards" and m["enable"]), None) - if not method: - await edit_or_send_message(target_message=message, text="❌ Оплата картами KassaAI временно недоступна.") - return - - try: - payment_url = await generate_kassai_payment_link(amount, tg_id, method) - - if not payment_url or payment_url == "https://fk.life/": - await edit_or_send_message( - target_message=message, - text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", - ) - return - - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], - ] - ) - - from database.models import User - from sqlalchemy import select - result = await session.execute(select(User.language_code).where(User.tg_id == tg_id)) - language_code = result.scalar_one_or_none() - amount_text = await format_for_user(session, tg_id, float(amount), language_code) - text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text) - - await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup) - except Exception as e: - logger.error(f"Ошибка при создании платежа KassaAI Cards для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) + """Функция быстрого потока для KassaI Cards.""" + await _handle_custom_amount_input_kassai( + event, session, "cards", pay_button_text, main_menu_text + ) async def handle_custom_amount_input_kassai_sbp( @@ -100,62 +197,8 @@ async def handle_custom_amount_input_kassai_sbp( pay_button_text: str = PAY_2, main_menu_text: str = MAIN_MENU, ): - """ - Функция быстрого потока для KassaI SBP - принимает недостающую сумму и формирует платеж через СБП. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. - """ - message = event.message - from_user = event.from_user - tg_id = from_user.id - - temp_data = await get_temporary_data(session, tg_id) - if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]: - await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.") - return - - amount = int(temp_data["data"].get("required_amount", 0)) - if amount <= 0: - await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") - return - - if amount < 10: - await edit_or_send_message(target_message=message, text="❌ Минимальная сумма для оплаты через СБП — 10₽.") - return - - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "sbp" and m["enable"]), None) - if not method: - await edit_or_send_message(target_message=message, text="❌ Оплата через СБП KassaAI временно недоступна.") - return + """Функция быстрого потока для KassaI SBP.""" + await _handle_custom_amount_input_kassai( + event, session, "sbp", pay_button_text, main_menu_text + ) - try: - payment_url = await generate_kassai_payment_link(amount, tg_id, method) - - if not payment_url or payment_url == "https://fk.life/": - await edit_or_send_message( - target_message=message, - text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", - ) - return - - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], - ] - ) - - from database.models import User - from sqlalchemy import select - result = await session.execute(select(User.language_code).where(User.tg_id == tg_id)) - language_code = result.scalar_one_or_none() - amount_text = await format_for_user(session, tg_id, float(amount), language_code) - text_out = DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text) - - await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup) - except Exception as e: - logger.error(f"Ошибка при создании платежа KassaAI SBP для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) diff --git a/handlers/payments/kassai/service.py b/handlers/payments/kassai/service.py index cb1ac35f..57a3a39e 100644 --- a/handlers/payments/kassai/service.py +++ b/handlers/payments/kassai/service.py @@ -1,15 +1,15 @@ import hashlib import hmac import time -import aiohttp +import aiohttp 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 sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from handlers.payments.currency_rates import format_for_user from config import ( KASSAI_API_KEY, @@ -20,25 +20,34 @@ from config import ( KASSAI_SUCCESS_URL, PROVIDERS_ENABLED, ) -from handlers.payments.providers import get_providers +from database.models import User from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2 +from handlers.payments.currency_rates import ( + format_for_user, + pick_currency, + to_rub, +) +from handlers.payments.keyboards import ( + build_amounts_keyboard, + parse_amount_from_callback, + pay_keyboard, + payment_options_for_user, +) +from handlers.payments.providers import get_providers from handlers.texts import ( ENTER_SUM, KASSAI_CARDS_DESCRIPTION, KASSAI_PAYMENT_MESSAGE, KASSAI_SBP_DESCRIPTION, ) -from handlers.payments.keyboards import build_amounts_keyboard, payment_options_for_user, parse_amount_from_callback, pay_keyboard from handlers.utils import edit_or_send_message -from database.models import User from logger import logger router = Router() async def get_user_language(session: AsyncSession, tg_id: int) -> str | None: - """Получает язык пользователя из базы данных""" - from sqlalchemy import select + """Получает язык пользователя из базы данных.""" result = await session.execute( select(User.language_code).where(User.tg_id == tg_id) ) @@ -46,6 +55,8 @@ async def get_user_language(session: AsyncSession, tg_id: int) -> str | None: class ReplenishBalanceKassaiState(StatesGroup): + """Состояния FSM для пополнения баланса через KassaI.""" + choosing_method = State() choosing_amount = State() waiting_for_payment_confirmation = State() @@ -71,18 +82,28 @@ KASSAI_PAYMENT_METHODS = [ ] - @router.callback_query(F.data == "pay_kassai") async def process_callback_pay_kassai( - callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, + method_name: str = None, ): + """Обработчик callback для инициализации платежа через KassaI.""" try: tg_id = callback_query.from_user.id logger.info(f"User {tg_id} initiated KassaAI payment.") await state.clear() if method_name: - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) + method = next( + ( + m + for m in KASSAI_PAYMENT_METHODS + if m["name"] == method_name and m["enable"] + ), + None, + ) if not method: try: await callback_query.message.delete() @@ -95,13 +116,15 @@ async def process_callback_pay_kassai( return language_code = await get_user_language(session, tg_id) - opts = await payment_options_for_user(session, tg_id, language_code) + opts = await payment_options_for_user( + session, tg_id, language_code, force_currency="RUB" + ) builder = build_amounts_keyboard( prefix=f"kassai_{method_name}", pattern="{prefix}_amount|{price}", back_cb="balance", custom_cb=f"kassai_custom_amount|{method_name}", - opts=opts + opts=opts, ) try: @@ -123,7 +146,12 @@ async def process_callback_pay_kassai( builder = InlineKeyboardBuilder() for method in KASSAI_PAYMENT_METHODS: if method["enable"]: - builder.row(InlineKeyboardButton(text=method["button"], callback_data=f"kassai_method|{method['name']}")) + builder.row( + InlineKeyboardButton( + text=method["button"], + callback_data=f"kassai_method|{method['name']}", + ) + ) builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) try: @@ -134,12 +162,20 @@ async def process_callback_pay_kassai( text="Выберите способ оплаты через KassaAI:", reply_markup=builder.as_markup(), ) - await state.update_data(message_id=new_msg.message_id, chat_id=new_msg.chat.id) + await state.update_data( + message_id=new_msg.message_id, chat_id=new_msg.chat.id + ) await state.set_state(ReplenishBalanceKassaiState.choosing_method) except Exception as e: - logger.error(f"Error in process_callback_pay_kassai for user {callback_query.message.chat.id}: {e}") - await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True) + logger.error( + f"Error in process_callback_pay_kassai for user " + f"{callback_query.message.chat.id}: {e}" + ) + await callback_query.answer( + "Произошла ошибка при инициализации платежа. Попробуйте позже.", + show_alert=True, + ) @router.callback_query(F.data.startswith("kassai_method|")) @@ -160,7 +196,9 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F tg_id = callback_query.from_user.id language_code = await get_user_language(session, tg_id) - opts = await payment_options_for_user(session, tg_id, language_code) + opts = await payment_options_for_user( + session, tg_id, language_code, force_currency="RUB" + ) builder = build_amounts_keyboard( prefix=f"kassai_{method_name}", pattern="{prefix}_amount|{price}", @@ -187,7 +225,6 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_kassai_{method_name}")) - from handlers.payments.currency_rates import pick_currency language_code = await get_user_language(session, callback_query.from_user.id) currency = pick_currency(language_code) @@ -216,7 +253,6 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, ) return - from handlers.payments.currency_rates import pick_currency language_code = await get_user_language(session, message.from_user.id) currency = pick_currency(language_code) @@ -256,7 +292,6 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, ) return - from handlers.payments.currency_rates import to_rub, format_for_user if currency == "RUB": amount_rub = user_amount else: @@ -278,7 +313,7 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext, confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance") tg_id = message.from_user.id - amount_text = await format_for_user(session, tg_id, float(amount_rub), language_code) + amount_text = await format_for_user(session, tg_id, float(amount_rub), language_code, force_currency="RUB") await edit_or_send_message( target_message=message, @@ -347,7 +382,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F tg_id = callback_query.from_user.id language_code = await get_user_language(session, tg_id) - amount_text = await format_for_user(session, tg_id, float(amount), language_code) + amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB") await edit_or_send_message( target_message=callback_query.message, diff --git a/handlers/payments/kassai/webhook.py b/handlers/payments/kassai/webhook.py index 529c5b0d..9a08a9fb 100644 --- a/handlers/payments/kassai/webhook.py +++ b/handlers/payments/kassai/webhook.py @@ -1,18 +1,43 @@ import hashlib + from aiohttp import web -from logger import logger -from config import KASSAI_SHOP_ID, KASSAI_SECRET_KEY -from database import add_payment, async_session_maker, get_payment_by_payment_id, update_balance, update_payment_status + +from config import KASSAI_SECRET_KEY, KASSAI_SHOP_ID +from database import ( + add_payment, + async_session_maker, + get_payment_by_payment_id, + update_balance, + update_payment_status, +) from handlers.payments.utils import send_payment_success_notification +from logger import logger def verify_kassai_signature(data: dict, signature: str) -> bool: + """Проверяет подпись webhook от KassaAI. + + Args: + data: Данные от webhook + signature: Подпись для проверки + + Returns: + True если подпись валидна, иначе False + """ try: - sign_string = f"{KASSAI_SHOP_ID}:{data.get('AMOUNT', '')}:{KASSAI_SECRET_KEY}:{data.get('MERCHANT_ORDER_ID', '')}" - expected_signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest() + sign_string = ( + f"{KASSAI_SHOP_ID}:{data.get('AMOUNT', '')}:" + f"{KASSAI_SECRET_KEY}:{data.get('MERCHANT_ORDER_ID', '')}" + ) + expected_signature = hashlib.md5( + sign_string.encode("utf-8") + ).hexdigest() result = signature.upper() == expected_signature.upper() if not result: - logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}") + logger.error( + f"KassaAI signature mismatch. " + f"Expected: {expected_signature}, Got: {signature}" + ) logger.error(f"Sign string: {sign_string}") else: logger.info("KassaAI webhook: подпись успешно проверена") @@ -23,46 +48,66 @@ def verify_kassai_signature(data: dict, signature: str) -> bool: async def kassai_webhook(request: web.Request): + """Обработчик webhook от KassaAI для aiohttp.""" try: - data = await request.post() + data = await request.post() logger.info(f"KassaAI webhook received: {dict(data)}") - signature = data.get('SIGN', '') + signature = data.get("SIGN", "") if not signature: logger.error("KassaAI webhook: отсутствует подпись") return web.Response(status=400) if not verify_kassai_signature(data, signature): logger.error("KassaAI webhook: неверная подпись") return web.Response(status=400) - - amount_raw = data.get('AMOUNT') - order_id = data.get('MERCHANT_ORDER_ID') - + + amount_raw = data.get("AMOUNT") + order_id = data.get("MERCHANT_ORDER_ID") + if not amount_raw or not order_id: - logger.error("KassaAI webhook: отсутствуют обязательные параметры") + logger.error( + "KassaAI webhook: отсутствуют обязательные параметры" + ) return web.Response(status=400) - + amount = float(amount_raw) - + try: - tg_id = int(order_id.split('_')[1]) + tg_id = int(order_id.split("_")[1]) except (IndexError, ValueError) as e: - logger.error(f"KassaAI webhook: не удалось извлечь tg_id из order_id {order_id}: {e}") + logger.error( + f"KassaAI webhook: не удалось извлечь tg_id " + f"из order_id {order_id}: {e}" + ) return web.Response(status=400) - - logger.info(f"KassaAI: успешный платёж {order_id} на сумму {amount} RUB для пользователя {tg_id}") - + + logger.info( + f"KassaAI: успешный платёж {order_id} на сумму {amount} RUB " + f"для пользователя {tg_id}" + ) + async with async_session_maker() as session: payment = await get_payment_by_payment_id(session, order_id) if payment: if payment.get("status") == "success": - logger.info(f"KassaAI: платёж {order_id} уже обработан") + logger.info( + f"KassaAI: платёж {order_id} уже обработан" + ) return web.Response(text="OK") - ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success") + ok = await update_payment_status( + session=session, + internal_id=int(payment["id"]), + new_status="success", + ) if not ok: - logger.error(f"KassaAI: не удалось обновить статус платежа {order_id}") + logger.error( + f"KassaAI: не удалось обновить статус " + f"платежа {order_id}" + ) return web.Response(status=500) await update_balance(session, tg_id, amount) - await send_payment_success_notification(tg_id, amount, session) + await send_payment_success_notification( + tg_id, amount, session + ) await session.commit() else: await add_payment( @@ -76,9 +121,14 @@ async def kassai_webhook(request: web.Request): metadata=None, ) await update_balance(session, tg_id, amount) - await send_payment_success_notification(tg_id, amount, session) + await send_payment_success_notification( + tg_id, amount, session + ) await session.commit() - logger.info(f"KassaAI: платёж {order_id} успешно обработан, баланс пользователя {tg_id} пополнен на {amount} RUB") + logger.info( + f"KassaAI: платёж {order_id} успешно обработан, " + f"баланс пользователя {tg_id} пополнен на {amount} RUB" + ) return web.Response(text="OK") except Exception as e: logger.error(f"Ошибка обработки KassaAI webhook: {e}")