From 5baf44640d402b0b8836101910dd7c941dac9a47 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:39:07 +0000 Subject: [PATCH 01/13] =?UTF-8?q?FIX=20"=D0=9D=D0=B0=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=20=D0=B2=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для работы с процентами мы должны делить на 100 --- handlers/payments/currency_rates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/handlers/payments/currency_rates.py b/handlers/payments/currency_rates.py index 331e3a37..fc29045b 100644 --- a/handlers/payments/currency_rates.py +++ b/handlers/payments/currency_rates.py @@ -69,7 +69,9 @@ async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = No rate = _q(Decimal("1") / rub_per_unit) if code != "RUB" and FX_MARKUP: - rate = _q(rate * (Decimal("1") + Decimal(str(FX_MARKUP)))) + # FX_MARKUP задаётся в процентах (например, 30 = 30%), поэтому делим на 100 + pct = Decimal(str(FX_MARKUP)) / Decimal("100") + rate = _q(rate * (Decimal("1") + pct)) cache[code] = (now, rate) return rate From c4bafc6c1d43cea5795b96cda8372928e63ddbe8 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:43:10 +0000 Subject: [PATCH 02/13] =?UTF-8?q?FIX=20"=D0=9D=D0=B0=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=20=D0=B2=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для работы с процентами мы должны делить на 100 --- handlers/payments/currency_rates.py | 1 - 1 file changed, 1 deletion(-) diff --git a/handlers/payments/currency_rates.py b/handlers/payments/currency_rates.py index fc29045b..c730c835 100644 --- a/handlers/payments/currency_rates.py +++ b/handlers/payments/currency_rates.py @@ -69,7 +69,6 @@ async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = No rate = _q(Decimal("1") / rub_per_unit) if code != "RUB" and FX_MARKUP: - # FX_MARKUP задаётся в процентах (например, 30 = 30%), поэтому делим на 100 pct = Decimal(str(FX_MARKUP)) / Decimal("100") rate = _q(rate * (Decimal("1") + pct)) From 45041982d5991e6b16bae3e5f7493dd4f9838b6c Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:45:06 +0000 Subject: [PATCH 03/13] =?UTF-8?q?FIX=20"=D0=94=D0=B2=D0=BE=D0=B9=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20Heleket"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убрал один роутер, и выход сразу на страницу пополнения баланса --- handlers/payments/heleket/heleket.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/handlers/payments/heleket/heleket.py b/handlers/payments/heleket/heleket.py index 7523eeb0..d387903f 100644 --- a/handlers/payments/heleket/heleket.py +++ b/handlers/payments/heleket/heleket.py @@ -51,8 +51,6 @@ HELEKET_PAYMENT_METHODS = [ }, ] - -@router.callback_query(F.data == "pay_heleket_crypto") async def process_callback_pay_heleket( callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None ): @@ -61,6 +59,12 @@ async def process_callback_pay_heleket( logger.info(f"User {tg_id} initiated Heleket payment.") await state.clear() + # Если метод не указан, а активен ровно один метод — пропускаем выбор метода + if not method_name: + enabled_methods = [m["name"] for m in HELEKET_PAYMENT_METHODS if m["enable"]] + if len(enabled_methods) == 1: + method_name = enabled_methods[0] + if method_name: method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) if not method: @@ -175,7 +179,7 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F ) ) builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay")) await edit_or_send_message( target_message=callback_query.message, @@ -193,7 +197,7 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat await state.update_data(heleket_method=method_name) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_heleket_{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto")) await edit_or_send_message( target_message=callback_query.message, From e19e00aa0489d5dd900512056808491f0dae26fd Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:46:42 +0000 Subject: [PATCH 04/13] =?UTF-8?q?FIX=20"=D0=9A=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/heleket/heleket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/handlers/payments/heleket/heleket.py b/handlers/payments/heleket/heleket.py index d387903f..cfa0aff4 100644 --- a/handlers/payments/heleket/heleket.py +++ b/handlers/payments/heleket/heleket.py @@ -59,7 +59,6 @@ async def process_callback_pay_heleket( logger.info(f"User {tg_id} initiated Heleket payment.") await state.clear() - # Если метод не указан, а активен ровно один метод — пропускаем выбор метода if not method_name: enabled_methods = [m["name"] for m in HELEKET_PAYMENT_METHODS if m["enable"]] if len(enabled_methods) == 1: From 4e1d7e51a98c8ae072ecb7168d63e3bd31bc9b2b Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:53:14 +0000 Subject: [PATCH 05/13] =?UTF-8?q?FIX=20"=D0=92=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D0=BB=20=D1=80=D0=BE=D1=83=D1=82=D0=B5=D1=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/heleket/heleket.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/handlers/payments/heleket/heleket.py b/handlers/payments/heleket/heleket.py index cfa0aff4..f8f1f99b 100644 --- a/handlers/payments/heleket/heleket.py +++ b/handlers/payments/heleket/heleket.py @@ -51,6 +51,8 @@ HELEKET_PAYMENT_METHODS = [ }, ] + +@router.callback_query(F.data == "pay_heleket_crypto") async def process_callback_pay_heleket( callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None ): From b0482cca1a5c2f7e1c0ae3532cdca159dabc5864 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:13:57 +0000 Subject: [PATCH 06/13] =?UTF-8?q?FIX=20"=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D1=8B=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=84=D0=BB=D0=BE=D1=83=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=81=D1=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/heleket/heleket.py | 82 ++++++++++++++- handlers/payments/kassai/kassai.py | 145 ++++++++++++++++++++++++++- handlers/payments/providers.py | 6 +- 3 files changed, 226 insertions(+), 7 deletions(-) diff --git a/handlers/payments/heleket/heleket.py b/handlers/payments/heleket/heleket.py index f8f1f99b..5a788fc0 100644 --- a/handlers/payments/heleket/heleket.py +++ b/handlers/payments/heleket/heleket.py @@ -22,10 +22,11 @@ from config import ( ) from handlers.payments.providers import get_providers from ..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, PAYMENT_OPTIONS +from handlers.buttons import BACK, HELEKET, PAY_2, MAIN_MENU +from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE, PAYMENT_OPTIONS from handlers.utils import edit_or_send_message -from database import add_payment, async_session_maker +from handlers.payments.currency_rates import format_for_user +from database import add_payment, async_session_maker, get_temporary_data from logger import logger @@ -451,3 +452,78 @@ async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) - except Exception as e: logger.error(f"Error creating Heleket payment: {e}") return "https://heleket.com/" + + +async def handle_custom_amount_input_heleket( + event: types.Message | types.CallbackQuery, + session: AsyncSession, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + """ + Функция быстрого потока для Heleket - принимает недостающую сумму и формирует платеж. + Работает с временными данными из fast_payment_flow для создания/продления/подарка. + """ + if isinstance(event, types.CallbackQuery): + 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 + 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 временно недоступен.") + return + method = enabled_methods[0] + else: + message = event + from_user = message.from_user + tg_id = from_user.id + text = message.text + if not text or not text.isdigit(): + await message.answer("Введите корректную сумму числом.") + return + amount = int(text) + if amount <= 0: + await message.answer("Сумма должна быть больше нуля.") + return + if amount < 10: + await message.answer("Минимальная сумма для оплаты криптовалютой — 10 рублей.") + return + enabled_methods = [m for m in HELEKET_PAYMENT_METHODS if m["enable"]] + if not enabled_methods: + await message.answer("❌ Способ оплаты Heleket временно недоступен.") + return + method = enabled_methods[0] + + try: + payment_url = await generate_heleket_payment_link(amount, tg_id, method) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", 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: + logger.error(f"Ошибка при создании платежа Heleket для пользователя {tg_id}: {e}") + await edit_or_send_message( + target_message=message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) diff --git a/handlers/payments/kassai/kassai.py b/handlers/payments/kassai/kassai.py index c0905805..87628737 100644 --- a/handlers/payments/kassai/kassai.py +++ b/handlers/payments/kassai/kassai.py @@ -21,8 +21,9 @@ from config import ( PROVIDERS_ENABLED, ) from handlers.payments.providers import get_providers -from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2 +from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2, MAIN_MENU from handlers.texts import ( + DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, KASSAI_CARDS_DESCRIPTION, KASSAI_PAYMENT_MESSAGE, @@ -30,6 +31,8 @@ from handlers.texts import ( PAYMENT_OPTIONS, ) 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 router = Router() @@ -397,3 +400,143 @@ def verify_kassai_signature(data: dict, signature: str) -> bool: except Exception as e: logger.error(f"Ошибка проверки подписи KassaAI: {e}") return False + + +async def handle_custom_amount_input_kassai_cards( + event: types.Message | types.CallbackQuery, + session: AsyncSession, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + """ + Функция быстрого потока для KassaI Cards - принимает недостающую сумму и формирует платеж картами. + Работает с временными данными из fast_payment_flow для создания/продления/подарка. + """ + if isinstance(event, types.CallbackQuery): + 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 + 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 + else: + message = event + from_user = message.from_user + tg_id = from_user.id + text = message.text + if not text or not text.isdigit(): + await message.answer("Введите корректную сумму числом.") + return + amount = int(text) + if amount <= 0: + await message.answer("Сумма должна быть больше нуля.") + return + method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "cards" and m["enable"]), None) + if not method: + await message.answer("❌ Оплата картами KassaAI временно недоступна.") + return + + try: + payment_url = await generate_kassai_payment_link(amount, tg_id, method) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", 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: + logger.error(f"Ошибка при создании платежа KassaAI Cards для пользователя {tg_id}: {e}") + await edit_or_send_message( + target_message=message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + + +async def handle_custom_amount_input_kassai_sbp( + event: types.Message | types.CallbackQuery, + session: AsyncSession, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + """ + Функция быстрого потока для KassaI SBP - принимает недостающую сумму и формирует платеж через СБП. + Работает с временными данными из fast_payment_flow для создания/продления/подарка. + """ + if isinstance(event, types.CallbackQuery): + 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 + else: + message = event + from_user = message.from_user + tg_id = from_user.id + text = message.text + if not text or not text.isdigit(): + await message.answer("Введите корректную сумму числом.") + return + amount = int(text) + if amount <= 0: + await message.answer("Сумма должна быть больше нуля.") + return + if amount < 10: + await message.answer("Минимальная сумма для оплаты через СБП — 10 рублей.") + return + method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "sbp" and m["enable"]), None) + if not method: + await message.answer("❌ Оплата через СБП KassaAI временно недоступна.") + return + + try: + payment_url = await generate_kassai_payment_link(amount, tg_id, method) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", 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: + 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/providers.py b/handlers/payments/providers.py index e2c2e040..ec3ad55d 100644 --- a/handlers/payments/providers.py +++ b/handlers/payments/providers.py @@ -20,12 +20,12 @@ PROVIDERS_BASE: Dict[str, dict] = { "KASSAI_CARDS": { "currency": "RUB", "value": "pay_kassai_cards", - "fast": None, + "fast": "handle_custom_amount_input_kassai_cards", }, "KASSAI_SBP": { "currency": "RUB", "value": "pay_kassai_sbp", - "fast": None, + "fast": "handle_custom_amount_input_kassai_sbp", }, "WATA_RU": { "currency": "RUB", @@ -45,7 +45,7 @@ PROVIDERS_BASE: Dict[str, dict] = { "HELEKET": { "currency": "USD", "value": "pay_heleket_crypto", - "fast": None, + "fast": "handle_custom_amount_input_heleket", }, "CRYPTOBOT": { "currency": "USD", From 752292cb4a3f4cd4f530bf84aa205e9f74a39e28 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:53:48 +0000 Subject: [PATCH 07/13] =?UTF-8?q?FIX=20"=D0=9F=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=9A=D0=B0=D1=81=D1=81=20?= =?UTF-8?q?=D0=BA=20=D0=A1=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D1=83?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82=D0=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heleket и KassaAI --- handlers/payments/__init__.py | 4 +- handlers/payments/fast_payment_flow.py | 6 +- handlers/payments/heleket/__init__.py | 3 + handlers/payments/heleket/handlers.py | 85 +++++ handlers/payments/heleket/service.py | 452 +++++++++++++++++++++++++ handlers/payments/heleket/webhook.py | 97 ++++++ handlers/payments/kassai/__init__.py | 3 + handlers/payments/kassai/handlers.py | 142 ++++++++ handlers/payments/kassai/service.py | 381 +++++++++++++++++++++ handlers/payments/kassai/webhook.py | 87 +++++ handlers/payments/pay.py | 18 - 11 files changed, 1257 insertions(+), 21 deletions(-) create mode 100644 handlers/payments/heleket/handlers.py create mode 100644 handlers/payments/heleket/service.py create mode 100644 handlers/payments/heleket/webhook.py create mode 100644 handlers/payments/kassai/handlers.py create mode 100644 handlers/payments/kassai/service.py create mode 100644 handlers/payments/kassai/webhook.py diff --git a/handlers/payments/__init__.py b/handlers/payments/__init__.py index 963de680..8e370f60 100644 --- a/handlers/payments/__init__.py +++ b/handlers/payments/__init__.py @@ -9,8 +9,8 @@ from .cryptobot import router as cryptobot_router from .fast_payment_flow import router as fast_payment_flow_router from .freekassa.freekassa_pay import router as freekassa_router from .gift import router as gift_router -from .heleket.heleket import router as heleket_router -from .kassai.kassai import router as kassai_router +from .heleket import router as heleket_router +from .kassai import router as kassai_router from .pay import router as pay_router from .robokassa import router as robokassa_router from .stars import router as stars_router diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py index ffa88969..db62fca6 100644 --- a/handlers/payments/fast_payment_flow.py +++ b/handlers/payments/fast_payment_flow.py @@ -39,7 +39,11 @@ async def _run_provider_flow( if not fast_name: return False - module_name = f"handlers.payments.{up.lower()}.handlers" + # Специальная логика для KassaI - оба метода в одном модуле + if up.startswith("KASSAI_"): + module_name = "handlers.payments.kassai.handlers" + else: + module_name = f"handlers.payments.{up.lower()}.handlers" try: module = importlib.import_module(module_name) diff --git a/handlers/payments/heleket/__init__.py b/handlers/payments/heleket/__init__.py index e69de29b..8a870356 100644 --- a/handlers/payments/heleket/__init__.py +++ b/handlers/payments/heleket/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("router",) + +from .handlers import router diff --git a/handlers/payments/heleket/handlers.py b/handlers/payments/heleket/handlers.py new file mode 100644 index 00000000..7b594079 --- /dev/null +++ b/handlers/payments/heleket/handlers.py @@ -0,0 +1,85 @@ +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + +from handlers.buttons import PAY_2, MAIN_MENU +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 + +router = Router(name="heleket_router") + +# Подключаем роутер из service.py для обработки всех callback'ов +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_custom_amount_input_heleket( + event, + session: AsyncSession, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + """ + Функция быстрого потока для 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="❌ Не удалось получить данные для оплаты.") + 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 + + # Выбираем единственный доступный метод Heleket (crypto) + 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 временно недоступен.") + return + method = enabled_methods[0] # Всегда crypto + + try: + payment_url = await generate_heleket_payment_link(amount, tg_id, method) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", None) + # Для Heleket показываем сумму в рублях, но платёж идёт в USD + 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: + logger.error(f"Ошибка при создании платежа Heleket для пользователя {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 new file mode 100644 index 00000000..0432d262 --- /dev/null +++ b/handlers/payments/heleket/service.py @@ -0,0 +1,452 @@ +import base64 +import hashlib +import json +import time +from decimal import Decimal, ROUND_HALF_UP + +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.ext.asyncio import AsyncSession + +from config import ( + HELEKET_API_KEY, + HELEKET_CALLBACK_URL, + HELEKET_MERCHANT_ID, + HELEKET_RETURN_URL, + HELEKET_SUCCESS_URL, + PROVIDERS_ENABLED, +) +from handlers.payments.providers import get_providers +from ..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, PAYMENT_OPTIONS +from handlers.utils import edit_or_send_message +from database import add_payment, async_session_maker +from logger import logger + + +router = Router() + + +class ReplenishBalanceHeleket(StatesGroup): + choosing_method = State() + choosing_amount = State() + waiting_for_payment_confirmation = State() + entering_custom_amount = State() + + +PROVIDERS = get_providers(PROVIDERS_ENABLED) +HELEKET_PAYMENT_METHODS = [ + { + "enable": bool(PROVIDERS.get("HELEKET", {}).get("enabled")), + "currency": (PROVIDERS.get("HELEKET", {}).get("currency") or "USD"), + "to_currency": None, + "name": "crypto", + "button": HELEKET, + "desc": HELEKET_CRYPTO_DESCRIPTION, + }, +] + + +async def process_callback_pay_heleket( + callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None +): + try: + tg_id = callback_query.message.chat.id + logger.info(f"User {tg_id} initiated Heleket payment.") + await state.clear() + + if not method_name: + enabled_methods = [m["name"] for m in HELEKET_PAYMENT_METHODS if m["enable"]] + if len(enabled_methods) == 1: + method_name = enabled_methods[0] + + if method_name: + method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) + if not method: + try: + await callback_query.message.delete() + except Exception: + pass + await callback_query.message.answer( + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + 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"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + try: + await callback_query.message.delete() + except Exception: + pass + new_msg = await callback_query.message.answer( + text=method["desc"], + reply_markup=builder.as_markup(), + ) + + await state.update_data( + heleket_method=method_name, + message_id=new_msg.message_id, + chat_id=new_msg.chat.id, + ) + await state.set_state(ReplenishBalanceHeleket.choosing_amount) + return + + builder = InlineKeyboardBuilder() + for method in HELEKET_PAYMENT_METHODS: + if method["enable"]: + builder.row( + InlineKeyboardButton(text=method["button"], callback_data=f"heleket_method|{method['name']}") + ) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + try: + await callback_query.message.delete() + except Exception: + pass + new_msg = await callback_query.message.answer( + text="Выберите способ оплаты через Heleket:", + reply_markup=builder.as_markup(), + ) + await state.update_data(message_id=new_msg.message_id, chat_id=new_msg.chat.id) + await state.set_state(ReplenishBalanceHeleket.choosing_method) + + except Exception as e: + logger.error(f"Error in process_callback_pay_heleket for user {callback_query.message.chat.id}: {e}") + await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True) + + +@router.callback_query(F.data.startswith("heleket_method|")) +async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext): + method_name = callback_query.data.split("|")[1] + method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(heleket_method=method_name) + + 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"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay")) + + await edit_or_send_message( + target_message=callback_query.message, + text=method["desc"], + reply_markup=builder.as_markup(), + force_text=True, + ) + await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id) + await state.set_state(ReplenishBalanceHeleket.choosing_amount) + + +@router.callback_query(F.data.startswith("heleket_custom_amount|")) +async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext): + method_name = callback_query.data.split("|")[1] + await state.update_data(heleket_method=method_name) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto")) + + 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(ReplenishBalanceHeleket.entering_custom_amount) + + +@router.message(ReplenishBalanceHeleket.entering_custom_amount) +async def handle_custom_amount_input(message: types.Message, state: FSMContext): + data = await state.get_data() + method_name = data.get("heleket_method") + method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + try: + amount = int(message.text.strip()) + if amount <= 0: + raise ValueError + if amount < 10: + await edit_or_send_message( + target_message=message, + text="Минимальная сумма для оплаты криптовалютой — 10 рублей.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + except Exception: + await edit_or_send_message( + target_message=message, + text="Некорректная сумма. Введите целое число больше 0.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(amount=amount) + payment_url = await generate_heleket_payment_link(amount, message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="balance")], + ] + ) + + await edit_or_send_message( + target_message=message, + text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + + await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) + + +async def process_fast_flow_heleket( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, + amount: int, + method_name: str = "crypto", +): + method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) + if not method: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + if amount <= 0: + await edit_or_send_message( + target_message=callback_query.message, + text="Некорректная сумма.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + if amount < 10: + await edit_or_send_message( + target_message=callback_query.message, + text="Минимальная сумма для оплаты криптовалютой — 10 рублей.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(heleket_method=method_name, amount=amount) + payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="balance")], + ] + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) + + +@router.callback_query(F.data.startswith("heleket_amount|")) +async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): + parts = callback_query.data.split("|") + method_name = parts[1] + amount_str = parts[2] + + method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + try: + amount = int(amount_str) + if amount <= 0: + raise ValueError + except Exception: + await edit_or_send_message( + target_message=callback_query.message, + text="Некорректная сумма.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(amount=amount) + payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="balance")], + ] + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + + await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) + + +async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -> str: + """ + Создание платежа в Heleket и получение ссылки на оплату. + amount — сумма в RUB, method['currency'] — валюта провайдера (обычно USD). + """ + url = "https://api.heleket.com/v1/payment" + unique_order_id = f"{int(time.time())}_{tg_id}" + + try: + async with aiohttp.ClientSession() as session: + pay_cur = str(method["currency"]).upper() + + if pay_cur == "RUB": + payment_amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + else: + rate = await get_rub_rate(pay_cur, session=session) + payment_amount = (Decimal(str(amount)) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + async with async_session_maker() as dbs: + await add_payment( + session=dbs, + tg_id=tg_id, + amount=float(amount), + payment_system="HELEKET", + status="pending", + currency="RUB", + payment_id=unique_order_id, + ) + + data = { + "amount": str(payment_amount), + "currency": method["currency"], + "order_id": unique_order_id, + "url_success": HELEKET_SUCCESS_URL, + "url_return": HELEKET_RETURN_URL, + "url_callback": HELEKET_CALLBACK_URL, + "additional_data": f"tg_id:{tg_id},rub_amount:{amount}", + } + if method.get("to_currency"): + data["to_currency"] = method["to_currency"] + + json_data = json.dumps(data, separators=(",", ":")) + base64_data = base64.b64encode(json_data.encode("utf-8")).decode("utf-8") + sign_string = base64_data + HELEKET_API_KEY + signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest() + + headers = { + "merchant": HELEKET_MERCHANT_ID, + "sign": signature, + "Content-Type": "application/json", + } + + async with session.post(url, headers=headers, data=json_data, timeout=60) as resp: + if resp.status == 200: + try: + resp_json = await resp.json() + if resp_json.get("state") == 0: + payment_url = resp_json.get("result", {}).get("url") + if payment_url: + logger.info(f"Heleket payment URL created for user {tg_id}") + return payment_url + else: + logger.error(f"Heleket: No URL in response: {resp_json}") + return "https://heleket.com/" + else: + logger.error(f"Heleket: Unsuccessful response: {resp_json}") + return "https://heleket.com/" + except Exception as e: + logger.error(f"Heleket: Error parsing JSON response: {e}") + text = await resp.text() + logger.error(f"Heleket: Response content: {text}") + return "https://heleket.com/" + else: + try: + error_json = await resp.json() + logger.error(f"Heleket API error: status={resp.status}, response={error_json}") + except Exception: + text = await resp.text() + logger.error(f"Heleket API error: status={resp.status}, non-JSON response: {text}") + return "https://heleket.com/" + except Exception as e: + logger.error(f"Error creating Heleket payment: {e}") + return "https://heleket.com/" diff --git a/handlers/payments/heleket/webhook.py b/handlers/payments/heleket/webhook.py new file mode 100644 index 00000000..9c7c31dd --- /dev/null +++ b/handlers/payments/heleket/webhook.py @@ -0,0 +1,97 @@ +import base64 +import hashlib +import json +from logger import logger +from config import HELEKET_API_KEY +from database import async_session_maker, update_payment_status, add_balance_to_user + + +def verify_heleket_signature(data: dict) -> bool: + try: + 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') + sign_string = base64_data + HELEKET_API_KEY + 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: строка для подписи: {sign_string}") + else: + logger.info("Heleket webhook: подпись успешно проверена") + return is_valid + except Exception as e: + logger.error(f"Ошибка проверки подписи Heleket webhook: {e}") + return False + + +async def process_heleket_webhook(data: dict) -> bool: + 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': + 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}") + 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]) + except Exception as e: + logger.error(f"Ошибка парсинга additional_data: {e}") + if not tg_id and '_' in order_id: + try: + tg_id = int(order_id.split('_')[1]) + except Exception as e: + logger.error(f"Ошибка извлечения tg_id из order_id: {e}") + if not tg_id: + logger.error(f"Не удалось извлечь tg_id из Heleket webhook: {data}") + return False + balance_amount = rub_amount if rub_amount else float(merchant_amount) + async with async_session_maker() as session: + await update_payment_status(session, order_id, "success") + await add_balance_to_user(session, tg_id, balance_amount) + await session.commit() + logger.info(f"Heleket: платёж {order_id} для пользователя {tg_id} успешно обработан, баланс пополнен на {balance_amount} RUB") + return True + elif status in ['fail', 'wrong_amount', 'cancel', 'system_fail']: + logger.warning(f"Heleket: неудачный платёж {order_id}, статус: {status}") + + async with async_session_maker() as session: + await update_payment_status(session, order_id, "failed") + await session.commit() + return True + else: + logger.info(f"Heleket: промежуточный статус {status} для платежа {order_id}") + return True + except Exception as e: + logger.error(f"Ошибка обработки Heleket webhook: {e}") + return False diff --git a/handlers/payments/kassai/__init__.py b/handlers/payments/kassai/__init__.py index e69de29b..8a870356 100644 --- a/handlers/payments/kassai/__init__.py +++ b/handlers/payments/kassai/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("router",) + +from .handlers import router diff --git a/handlers/payments/kassai/handlers.py b/handlers/payments/kassai/handlers.py new file mode 100644 index 00000000..a0dae638 --- /dev/null +++ b/handlers/payments/kassai/handlers.py @@ -0,0 +1,142 @@ +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + +from handlers.buttons import PAY_2, MAIN_MENU +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 + +router = Router(name="kassai_router") + +# Подключаем роутер из service.py для обработки всех callback'ов +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") + + +@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_custom_amount_input_kassai_cards( + event, + session: AsyncSession, + 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 + + # Используем метод карт + 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) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", 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: + logger.error(f"Ошибка при создании платежа KassaAI Cards для пользователя {tg_id}: {e}") + await edit_or_send_message( + target_message=message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + + +async def handle_custom_amount_input_kassai_sbp( + event, + session: AsyncSession, + 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 + + try: + payment_url = await generate_kassai_payment_link(amount, tg_id, method) + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + + language_code = getattr(from_user, "language_code", 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: + 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 new file mode 100644 index 00000000..7e051e4d --- /dev/null +++ b/handlers/payments/kassai/service.py @@ -0,0 +1,381 @@ +import hashlib +import hmac +import time +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.ext.asyncio import AsyncSession + +from config import ( + KASSAI_API_KEY, + KASSAI_DOMAIN, + KASSAI_FAILURE_URL, + KASSAI_IP, + KASSAI_SECRET_KEY, + KASSAI_SHOP_ID, + KASSAI_SUCCESS_URL, + PROVIDERS_ENABLED, +) +from handlers.payments.providers import get_providers +from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2 +from handlers.texts import ( + ENTER_SUM, + KASSAI_CARDS_DESCRIPTION, + KASSAI_PAYMENT_MESSAGE, + KASSAI_SBP_DESCRIPTION, + PAYMENT_OPTIONS, +) +from handlers.utils import edit_or_send_message +from logger import logger + +router = Router() + + +class ReplenishBalanceKassaiState(StatesGroup): + choosing_method = State() + choosing_amount = State() + waiting_for_payment_confirmation = State() + entering_custom_amount = State() + + +PROVIDERS = get_providers(PROVIDERS_ENABLED) +KASSAI_PAYMENT_METHODS = [ + { + "enable": bool(PROVIDERS.get("KASSAI_CARDS", {}).get("enabled")), + "method": 36, + "name": "cards", + "button": KASSAI_CARDS, + "desc": KASSAI_CARDS_DESCRIPTION, + }, + { + "enable": bool(PROVIDERS.get("KASSAI_SBP", {}).get("enabled")), + "method": 44, + "name": "sbp", + "button": KASSAI_SBP, + "desc": KASSAI_SBP_DESCRIPTION, + }, +] + + + +@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 +): + try: + tg_id = callback_query.message.chat.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) + if not method: + try: + await callback_query.message.delete() + except Exception: + pass + await callback_query.message.answer( + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + 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"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + try: + await callback_query.message.delete() + except Exception: + pass + new_msg = await callback_query.message.answer( + text=method["desc"], + reply_markup=builder.as_markup(), + ) + await state.update_data( + kassai_method=method_name, + message_id=new_msg.message_id, + chat_id=new_msg.chat.id, + ) + await state.set_state(ReplenishBalanceKassaiState.choosing_amount) + return + + 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=BACK, callback_data="balance")) + + try: + await callback_query.message.delete() + except Exception: + pass + new_msg = await callback_query.message.answer( + 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.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) + + +@router.callback_query(F.data.startswith("kassai_method|")) +async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext): + method_name = callback_query.data.split("|")[1] + method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(kassai_method=method_name) + + 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"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_kassai")) + + await edit_or_send_message( + target_message=callback_query.message, + text=method["desc"], + reply_markup=builder.as_markup(), + force_text=True, + ) + await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id) + await state.set_state(ReplenishBalanceKassaiState.choosing_amount) + + +@router.callback_query(F.data.startswith("kassai_custom_amount|")) +async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext): + method_name = callback_query.data.split("|")[1] + await state.update_data(kassai_method=method_name) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_kassai_{method_name}")) + + 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(ReplenishBalanceKassaiState.entering_custom_amount) + + +@router.message(ReplenishBalanceKassaiState.entering_custom_amount) +async def handle_custom_amount_input(message: types.Message, state: FSMContext): + data = await state.get_data() + method_name = data.get("kassai_method") + method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + try: + amount = int(message.text.strip()) + if amount <= 0: + raise ValueError + if method_name == "sbp" and amount < 10: + await edit_or_send_message( + target_message=message, + text="Минимальная сумма для оплаты через СБП — 10 рублей.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + except Exception: + await edit_or_send_message( + target_message=message, + text="Некорректная сумма. Введите целое число больше 0.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(amount=amount) + payment_url = await generate_kassai_payment_link(amount, message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="balance")], + ] + ) + + await edit_or_send_message( + target_message=message, + text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + + await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) + + +@router.callback_query(F.data.startswith("kassai_amount|")) +async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): + parts = callback_query.data.split("|") + method_name = parts[1] + amount_str = parts[2] + + method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) + + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + try: + amount = int(amount_str) + if amount <= 0: + raise ValueError + except Exception: + await edit_or_send_message( + target_message=callback_query.message, + text="Некорректная сумма.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + force_text=True, + ) + return + + await state.update_data(amount=amount) + payment_url = await generate_kassai_payment_link(amount, callback_query.message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="balance")], + ] + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + + await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) + + +async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) -> str: + """ + Создание заказа в KassaAI и получение ссылки на оплату + """ + nonce = int(time.time()) + unique_payment_id = f"{nonce}_{tg_id}" + url = "https://api.fk.life/v1/orders/create" + + headers = {"Content-Type": "application/json"} + + client_email = f"{tg_id}@{KASSAI_DOMAIN}" + client_ip = KASSAI_IP + + data_for_signature = { + "shopId": KASSAI_SHOP_ID, + "nonce": nonce, + "i": method["method"], + "email": client_email, + "ip": client_ip, + "amount": int(amount), + "currency": "RUB", + "success_url": KASSAI_SUCCESS_URL, + "failure_url": KASSAI_FAILURE_URL, + "paymentId": unique_payment_id, + } + + sign_string = "|".join(str(data_for_signature[k]) for k in sorted(data_for_signature.keys())) + signature = hmac.new(KASSAI_API_KEY.encode("utf-8"), sign_string.encode("utf-8"), hashlib.sha256).hexdigest() + + data = {**data_for_signature, "signature": signature} + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=data, timeout=60) as resp: + if resp.status == 200: + try: + resp_json = await resp.json() + if resp_json.get("type") == "success": + payment_url = resp_json.get("location") + if payment_url: + logger.info(f"KassaAI payment URL created for user {tg_id}") + return payment_url + logger.error(f"KassaAI: No location in response: {resp_json}") + return "https://fk.life/" + logger.error(f"KassaAI: Unsuccessful response: {resp_json}") + return "https://fk.life/" + except Exception as e: + logger.error(f"KassaAI: Error parsing JSON response: {e}") + text = await resp.text() + logger.error(f"KassaAI: Response content: {text}") + return "https://fk.life/" + else: + try: + error_json = await resp.json() + logger.error(f"KassaAI API error: status={resp.status}, response={error_json}") + except Exception: + text = await resp.text() + logger.error(f"KassaAI API error: status={resp.status}, non-JSON response: {text}") + return "https://fk.life/" + except Exception as e: + logger.error(f"Error creating KassaAI order: {e}") + return "https://fk.life/" diff --git a/handlers/payments/kassai/webhook.py b/handlers/payments/kassai/webhook.py new file mode 100644 index 00000000..d3c44f00 --- /dev/null +++ b/handlers/payments/kassai/webhook.py @@ -0,0 +1,87 @@ +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 handlers.payments.utils import send_payment_success_notification + + +def verify_kassai_signature(data: dict, signature: str) -> bool: + 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() + result = signature.upper() == expected_signature.upper() + if not result: + logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}") + logger.error(f"Sign string: {sign_string}") + else: + logger.info("KassaAI webhook: подпись успешно проверена") + return result + except Exception as e: + logger.error(f"Ошибка проверки подписи KassaAI: {e}") + return False + + +async def kassai_webhook(request: web.Request): + try: + data = await request.post() + logger.info(f"KassaAI webhook received: {dict(data)}") + 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') + status = data.get('STATUS', '') + + if not amount_raw or not order_id: + logger.error("KassaAI webhook: отсутствуют обязательные параметры") + return web.Response(status=400) + if status.upper() != 'SUCCESS': + logger.warning(f"KassaAI webhook: неуспешный статус {status} для заказа {order_id}") + return web.Response(text="OK") + + amount = float(amount_raw) + + try: + tg_id = int(order_id.split('_')[1]) + except (IndexError, ValueError) as e: + logger.error(f"KassaAI webhook: не удалось извлечь tg_id из order_id {order_id}: {e}") + return web.Response(status=400) + + logger.info(f"KassaAI: успешный платёж {order_id} на сумму {amount} RUB для пользователя {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} уже обработан") + return web.Response(text="OK") + ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success") + if not ok: + logger.error(f"KassaAI: не удалось обновить статус платежа {order_id}") + return web.Response(status=500) + else: + await add_payment( + session=session, + tg_id=tg_id, + amount=amount, + payment_system="KASSAI", + status="success", + currency="RUB", + payment_id=order_id, + metadata=None, + ) + + await update_balance(session, tg_id, amount) + await send_payment_success_notification(tg_id, amount, session) + await session.commit() + logger.info(f"KassaAI: платёж {order_id} успешно обработан, баланс пользователя {tg_id} пополнен на {amount} RUB") + return web.Response(text="OK") + except Exception as e: + logger.error(f"Ошибка обработки KassaAI webhook: {e}") + return web.Response(status=500) diff --git a/handlers/payments/pay.py b/handlers/payments/pay.py index 216e539a..1a9a0960 100644 --- a/handlers/payments/pay.py +++ b/handlers/payments/pay.py @@ -15,8 +15,6 @@ from database import get_last_payments from database.models import User from handlers import buttons as btn -from handlers.payments.heleket.heleket import process_callback_pay_heleket -from handlers.payments.kassai.kassai import process_callback_pay_kassai from handlers.payments.stars.handlers import process_callback_pay_stars from handlers.payments.tribute.handlers import process_callback_pay_tribute from handlers.payments.wata.wata import process_callback_pay_wata @@ -181,22 +179,6 @@ async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): await process_callback_pay_wata(callback_query, state, session, cassa_name="int") - -@router.callback_query(F.data == "pay_kassai_cards") -async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): - 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: CallbackQuery, state: FSMContext, session: AsyncSession): - await process_callback_pay_kassai(callback_query, state, session, method_name="sbp") - - -@router.callback_query(F.data == "pay_heleket_crypto") -async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): - await process_callback_pay_heleket(callback_query, state, session, method_name="crypto") - - @router.callback_query(F.data == "pay_tribute") async def handle_pay_tribute(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): await process_callback_pay_tribute(callback_query, state, session) From b4443678878e513f57f043f6a44f4206c58e65d7 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:57:00 +0000 Subject: [PATCH 08/13] =?UTF-8?q?FIX=20"=D0=9A=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=202"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/heleket/handlers.py | 7 +------ handlers/payments/kassai/handlers.py | 5 ----- handlers/payments/kassai/service.py | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/handlers/payments/heleket/handlers.py b/handlers/payments/heleket/handlers.py index 7b594079..f84d3cf0 100644 --- a/handlers/payments/heleket/handlers.py +++ b/handlers/payments/heleket/handlers.py @@ -14,8 +14,6 @@ from .service import HELEKET_PAYMENT_METHODS, generate_heleket_payment_link, pro from .service import router as service_router router = Router(name="heleket_router") - -# Подключаем роутер из service.py для обработки всех callback'ов router.include_router(service_router) @@ -48,17 +46,15 @@ async def handle_custom_amount_input_heleket( await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") return - # Проверяем минимальную сумму для криптоплатежей if amount < 10: await edit_or_send_message(target_message=message, text="❌ Минимальная сумма для оплаты криптовалютой — 10 рублей.") return - # Выбираем единственный доступный метод Heleket (crypto) 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 временно недоступен.") return - method = enabled_methods[0] # Всегда crypto + method = enabled_methods[0] try: payment_url = await generate_heleket_payment_link(amount, tg_id, method) @@ -71,7 +67,6 @@ async def handle_custom_amount_input_heleket( ) language_code = getattr(from_user, "language_code", None) - # Для Heleket показываем сумму в рублях, но платёж идёт в USD 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) diff --git a/handlers/payments/kassai/handlers.py b/handlers/payments/kassai/handlers.py index a0dae638..717d5885 100644 --- a/handlers/payments/kassai/handlers.py +++ b/handlers/payments/kassai/handlers.py @@ -14,8 +14,6 @@ from .service import KASSAI_PAYMENT_METHODS, generate_kassai_payment_link, proce from .service import router as service_router router = Router(name="kassai_router") - -# Подключаем роутер из service.py для обработки всех callback'ов router.include_router(service_router) @@ -53,7 +51,6 @@ async def handle_custom_amount_input_kassai_cards( await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") 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 временно недоступна.") @@ -107,12 +104,10 @@ async def handle_custom_amount_input_kassai_sbp( 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 временно недоступна.") diff --git a/handlers/payments/kassai/service.py b/handlers/payments/kassai/service.py index 7e051e4d..cba05a1a 100644 --- a/handlers/payments/kassai/service.py +++ b/handlers/payments/kassai/service.py @@ -15,7 +15,6 @@ from config import ( KASSAI_DOMAIN, KASSAI_FAILURE_URL, KASSAI_IP, - KASSAI_SECRET_KEY, KASSAI_SHOP_ID, KASSAI_SUCCESS_URL, PROVIDERS_ENABLED, From 3a23883e465aa83c97fe4a902b514137a1e0fa54 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:58:33 +1000 Subject: [PATCH 09/13] Delete handlers/payments/heleket/heleket.py --- handlers/payments/heleket/heleket.py | 529 --------------------------- 1 file changed, 529 deletions(-) delete mode 100644 handlers/payments/heleket/heleket.py diff --git a/handlers/payments/heleket/heleket.py b/handlers/payments/heleket/heleket.py deleted file mode 100644 index 5a788fc0..00000000 --- a/handlers/payments/heleket/heleket.py +++ /dev/null @@ -1,529 +0,0 @@ -import base64 -import hashlib -import json -import time -from decimal import Decimal, ROUND_HALF_UP - -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.ext.asyncio import AsyncSession - -from config import ( - HELEKET_API_KEY, - HELEKET_CALLBACK_URL, - HELEKET_MERCHANT_ID, - HELEKET_RETURN_URL, - HELEKET_SUCCESS_URL, - PROVIDERS_ENABLED, -) -from handlers.payments.providers import get_providers -from ..currency_rates import get_rub_rate -from handlers.buttons import BACK, HELEKET, PAY_2, MAIN_MENU -from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE, PAYMENT_OPTIONS -from handlers.utils import edit_or_send_message -from handlers.payments.currency_rates import format_for_user -from database import add_payment, async_session_maker, get_temporary_data -from logger import logger - - -router = Router() - - -class ReplenishBalanceHeleket(StatesGroup): - choosing_method = State() - choosing_amount = State() - waiting_for_payment_confirmation = State() - entering_custom_amount = State() - - -PROVIDERS = get_providers(PROVIDERS_ENABLED) -HELEKET_PAYMENT_METHODS = [ - { - "enable": bool(PROVIDERS.get("HELEKET", {}).get("enabled")), - "currency": (PROVIDERS.get("HELEKET", {}).get("currency") or "USD"), - "to_currency": None, - "name": "crypto", - "button": HELEKET, - "desc": HELEKET_CRYPTO_DESCRIPTION, - }, -] - - -@router.callback_query(F.data == "pay_heleket_crypto") -async def process_callback_pay_heleket( - callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None -): - try: - tg_id = callback_query.message.chat.id - logger.info(f"User {tg_id} initiated Heleket payment.") - await state.clear() - - if not method_name: - enabled_methods = [m["name"] for m in HELEKET_PAYMENT_METHODS if m["enable"]] - if len(enabled_methods) == 1: - method_name = enabled_methods[0] - - if method_name: - method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) - if not method: - try: - await callback_query.message.delete() - except Exception: - pass - await callback_query.message.answer( - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) - return - - 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"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ), - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", - ), - ) - else: - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ) - ) - builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) - - try: - await callback_query.message.delete() - except Exception: - pass - new_msg = await callback_query.message.answer( - text=method["desc"], - reply_markup=builder.as_markup(), - ) - - await state.update_data( - heleket_method=method_name, - message_id=new_msg.message_id, - chat_id=new_msg.chat.id, - ) - await state.set_state(ReplenishBalanceHeleket.choosing_amount) - return - - builder = InlineKeyboardBuilder() - for method in HELEKET_PAYMENT_METHODS: - if method["enable"]: - builder.row( - InlineKeyboardButton(text=method["button"], callback_data=f"heleket_method|{method['name']}") - ) - builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) - - try: - await callback_query.message.delete() - except Exception: - pass - new_msg = await callback_query.message.answer( - text="Выберите способ оплаты через Heleket:", - reply_markup=builder.as_markup(), - ) - await state.update_data(message_id=new_msg.message_id, chat_id=new_msg.chat.id) - await state.set_state(ReplenishBalanceHeleket.choosing_method) - - except Exception as e: - logger.error(f"Error in process_callback_pay_heleket for user {callback_query.message.chat.id}: {e}") - await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True) - - -@router.callback_query(F.data.startswith("heleket_method|")) -async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext): - method_name = callback_query.data.split("|")[1] - method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(heleket_method=method_name) - - 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"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ), - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", - ), - ) - else: - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ) - ) - builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay")) - - await edit_or_send_message( - target_message=callback_query.message, - text=method["desc"], - reply_markup=builder.as_markup(), - force_text=True, - ) - await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id) - await state.set_state(ReplenishBalanceHeleket.choosing_amount) - - -@router.callback_query(F.data.startswith("heleket_custom_amount|")) -async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext): - method_name = callback_query.data.split("|")[1] - await state.update_data(heleket_method=method_name) - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket_crypto")) - - 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(ReplenishBalanceHeleket.entering_custom_amount) - - -@router.message(ReplenishBalanceHeleket.entering_custom_amount) -async def handle_custom_amount_input(message: types.Message, state: FSMContext): - data = await state.get_data() - method_name = data.get("heleket_method") - method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - try: - amount = int(message.text.strip()) - if amount <= 0: - raise ValueError - if amount < 10: - await edit_or_send_message( - target_message=message, - text="Минимальная сумма для оплаты криптовалютой — 10 рублей.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - except Exception: - await edit_or_send_message( - target_message=message, - text="Некорректная сумма. Введите целое число больше 0.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(amount=amount) - payment_url = await generate_heleket_payment_link(amount, message.chat.id, method) - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="balance")], - ] - ) - - await edit_or_send_message( - target_message=message, - text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - force_text=True, - ) - - await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) - - -async def process_fast_flow_heleket( - callback_query: types.CallbackQuery, - state: FSMContext, - session: AsyncSession, - amount: int, - method_name: str = "crypto", -): - method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None) - if not method: - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - if amount <= 0: - await edit_or_send_message( - target_message=callback_query.message, - text="Некорректная сумма.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - if amount < 10: - await edit_or_send_message( - target_message=callback_query.message, - text="Минимальная сумма для оплаты криптовалютой — 10 рублей.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(heleket_method=method_name, amount=amount) - payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method) - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="balance")], - ] - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - force_text=True, - ) - await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) - - -@router.callback_query(F.data.startswith("heleket_amount|")) -async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): - parts = callback_query.data.split("|") - method_name = parts[1] - amount_str = parts[2] - - method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - try: - amount = int(amount_str) - if amount <= 0: - raise ValueError - except Exception: - await edit_or_send_message( - target_message=callback_query.message, - text="Некорректная сумма.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(amount=amount) - payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method) - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="balance")], - ] - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=HELEKET_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - force_text=True, - ) - - await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation) - - -async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -> str: - """ - Создание платежа в Heleket и получение ссылки на оплату. - amount — сумма в RUB, method['currency'] — валюта провайдера (обычно USD). - """ - url = "https://api.heleket.com/v1/payment" - unique_order_id = f"{int(time.time())}_{tg_id}" - - try: - async with aiohttp.ClientSession() as session: - pay_cur = str(method["currency"]).upper() - - if pay_cur == "RUB": - payment_amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - else: - rate = await get_rub_rate(pay_cur, session=session) - payment_amount = (Decimal(str(amount)) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - async with async_session_maker() as dbs: - await add_payment( - session=dbs, - tg_id=tg_id, - amount=float(amount), - payment_system="HELEKET", - status="pending", - currency="RUB", - payment_id=unique_order_id, - ) - - data = { - "amount": str(payment_amount), - "currency": method["currency"], - "order_id": unique_order_id, - "url_success": HELEKET_SUCCESS_URL, - "url_return": HELEKET_RETURN_URL, - "url_callback": HELEKET_CALLBACK_URL, - "additional_data": f"tg_id:{tg_id},rub_amount:{amount}", - } - if method.get("to_currency"): - data["to_currency"] = method["to_currency"] - - json_data = json.dumps(data, separators=(",", ":")) - base64_data = base64.b64encode(json_data.encode("utf-8")).decode("utf-8") - sign_string = base64_data + HELEKET_API_KEY - signature = hashlib.md5(sign_string.encode("utf-8")).hexdigest() - - headers = { - "merchant": HELEKET_MERCHANT_ID, - "sign": signature, - "Content-Type": "application/json", - } - - async with session.post(url, headers=headers, data=json_data, timeout=60) as resp: - if resp.status == 200: - try: - resp_json = await resp.json() - if resp_json.get("state") == 0: - payment_url = resp_json.get("result", {}).get("url") - if payment_url: - logger.info(f"Heleket payment URL created for user {tg_id}") - return payment_url - else: - logger.error(f"Heleket: No URL in response: {resp_json}") - return "https://heleket.com/" - else: - logger.error(f"Heleket: Unsuccessful response: {resp_json}") - return "https://heleket.com/" - except Exception as e: - logger.error(f"Heleket: Error parsing JSON response: {e}") - text = await resp.text() - logger.error(f"Heleket: Response content: {text}") - return "https://heleket.com/" - else: - try: - error_json = await resp.json() - logger.error(f"Heleket API error: status={resp.status}, response={error_json}") - except Exception: - text = await resp.text() - logger.error(f"Heleket API error: status={resp.status}, non-JSON response: {text}") - return "https://heleket.com/" - except Exception as e: - logger.error(f"Error creating Heleket payment: {e}") - return "https://heleket.com/" - - -async def handle_custom_amount_input_heleket( - event: types.Message | types.CallbackQuery, - session: AsyncSession, - pay_button_text: str = PAY_2, - main_menu_text: str = MAIN_MENU, -): - """ - Функция быстрого потока для Heleket - принимает недостающую сумму и формирует платеж. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. - """ - if isinstance(event, types.CallbackQuery): - 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 - 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 временно недоступен.") - return - method = enabled_methods[0] - else: - message = event - from_user = message.from_user - tg_id = from_user.id - text = message.text - if not text or not text.isdigit(): - await message.answer("Введите корректную сумму числом.") - return - amount = int(text) - if amount <= 0: - await message.answer("Сумма должна быть больше нуля.") - return - if amount < 10: - await message.answer("Минимальная сумма для оплаты криптовалютой — 10 рублей.") - return - enabled_methods = [m for m in HELEKET_PAYMENT_METHODS if m["enable"]] - if not enabled_methods: - await message.answer("❌ Способ оплаты Heleket временно недоступен.") - return - method = enabled_methods[0] - - try: - payment_url = await generate_heleket_payment_link(amount, tg_id, method) - - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], - ] - ) - - language_code = getattr(from_user, "language_code", 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: - logger.error(f"Ошибка при создании платежа Heleket для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) From 5e6333d08e612ef281f242198d642a8c8cb53314 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:58:50 +1000 Subject: [PATCH 10/13] Delete handlers/payments/kassai/kassai.py --- handlers/payments/kassai/kassai.py | 542 ----------------------------- 1 file changed, 542 deletions(-) delete mode 100644 handlers/payments/kassai/kassai.py diff --git a/handlers/payments/kassai/kassai.py b/handlers/payments/kassai/kassai.py deleted file mode 100644 index 87628737..00000000 --- a/handlers/payments/kassai/kassai.py +++ /dev/null @@ -1,542 +0,0 @@ -import hashlib -import hmac -import time -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.ext.asyncio import AsyncSession - -from config import ( - KASSAI_API_KEY, - KASSAI_DOMAIN, - KASSAI_FAILURE_URL, - KASSAI_IP, - KASSAI_SECRET_KEY, - KASSAI_SHOP_ID, - KASSAI_SUCCESS_URL, - PROVIDERS_ENABLED, -) -from handlers.payments.providers import get_providers -from handlers.buttons import BACK, KASSAI_CARDS, KASSAI_SBP, PAY_2, MAIN_MENU -from handlers.texts import ( - DEFAULT_PAYMENT_MESSAGE, - ENTER_SUM, - KASSAI_CARDS_DESCRIPTION, - KASSAI_PAYMENT_MESSAGE, - KASSAI_SBP_DESCRIPTION, - PAYMENT_OPTIONS, -) -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 - -router = Router() - - -class ReplenishBalanceKassaiState(StatesGroup): - choosing_method = State() - choosing_amount = State() - waiting_for_payment_confirmation = State() - entering_custom_amount = State() - - -PROVIDERS = get_providers(PROVIDERS_ENABLED) -KASSAI_PAYMENT_METHODS = [ - { - "enable": bool(PROVIDERS.get("KASSAI_CARDS", {}).get("enabled")), - "method": 36, - "name": "cards", - "button": KASSAI_CARDS, - "desc": KASSAI_CARDS_DESCRIPTION, - }, - { - "enable": bool(PROVIDERS.get("KASSAI_SBP", {}).get("enabled")), - "method": 44, - "name": "sbp", - "button": KASSAI_SBP, - "desc": KASSAI_SBP_DESCRIPTION, - }, -] - - - -@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 -): - try: - tg_id = callback_query.message.chat.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) - if not method: - try: - await callback_query.message.delete() - except Exception: - pass - await callback_query.message.answer( - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) - return - - 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"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ), - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", - ), - ) - else: - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ) - ) - builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) - - try: - await callback_query.message.delete() - except Exception: - pass - new_msg = await callback_query.message.answer( - text=method["desc"], - reply_markup=builder.as_markup(), - ) - await state.update_data( - kassai_method=method_name, - message_id=new_msg.message_id, - chat_id=new_msg.chat.id, - ) - await state.set_state(ReplenishBalanceKassaiState.choosing_amount) - return - - 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=BACK, callback_data="balance")) - - try: - await callback_query.message.delete() - except Exception: - pass - new_msg = await callback_query.message.answer( - 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.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) - - -@router.callback_query(F.data.startswith("kassai_method|")) -async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext): - method_name = callback_query.data.split("|")[1] - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(kassai_method=method_name) - - 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"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ), - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]['callback_data'].split('|')[1]}", - ), - ) - else: - builder.row( - InlineKeyboardButton( - text=PAYMENT_OPTIONS[i]["text"], - callback_data=f"kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]['callback_data'].split('|')[1]}", - ) - ) - builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_kassai")) - - await edit_or_send_message( - target_message=callback_query.message, - text=method["desc"], - reply_markup=builder.as_markup(), - force_text=True, - ) - await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id) - await state.set_state(ReplenishBalanceKassaiState.choosing_amount) - - -@router.callback_query(F.data.startswith("kassai_custom_amount|")) -async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext): - method_name = callback_query.data.split("|")[1] - await state.update_data(kassai_method=method_name) - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_kassai_{method_name}")) - - 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(ReplenishBalanceKassaiState.entering_custom_amount) - - -@router.message(ReplenishBalanceKassaiState.entering_custom_amount) -async def handle_custom_amount_input(message: types.Message, state: FSMContext): - data = await state.get_data() - method_name = data.get("kassai_method") - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - try: - amount = int(message.text.strip()) - if amount <= 0: - raise ValueError - if method_name == "sbp" and amount < 10: - await edit_or_send_message( - target_message=message, - text="Минимальная сумма для оплаты через СБП — 10 рублей.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - except Exception: - await edit_or_send_message( - target_message=message, - text="Некорректная сумма. Введите целое число больше 0.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(amount=amount) - payment_url = await generate_kassai_payment_link(amount, message.chat.id, method) - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="balance")], - ] - ) - - await edit_or_send_message( - target_message=message, - text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - force_text=True, - ) - - await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) - - -@router.callback_query(F.data.startswith("kassai_amount|")) -async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): - parts = callback_query.data.split("|") - method_name = parts[1] - amount_str = parts[2] - - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) - - if not method or not method["enable"]: - await edit_or_send_message( - target_message=callback_query.message, - text="Ошибка: выбранный способ оплаты недоступен.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - try: - amount = int(amount_str) - if amount <= 0: - raise ValueError - except Exception: - await edit_or_send_message( - target_message=callback_query.message, - text="Некорректная сумма.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - force_text=True, - ) - return - - await state.update_data(amount=amount) - payment_url = await generate_kassai_payment_link(amount, callback_query.message.chat.id, method) - - confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=PAY_2, url=payment_url)], - [InlineKeyboardButton(text=BACK, callback_data="balance")], - ] - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), - reply_markup=confirm_keyboard, - force_text=True, - ) - - await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) - - -async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) -> str: - """ - Создание заказа в KassaAI и получение ссылки на оплату - """ - nonce = int(time.time()) - unique_payment_id = f"{nonce}_{tg_id}" - url = "https://api.fk.life/v1/orders/create" - - headers = {"Content-Type": "application/json"} - - client_email = f"{tg_id}@{KASSAI_DOMAIN}" - client_ip = KASSAI_IP - - data_for_signature = { - "shopId": KASSAI_SHOP_ID, - "nonce": nonce, - "i": method["method"], - "email": client_email, - "ip": client_ip, - "amount": int(amount), - "currency": "RUB", - "success_url": KASSAI_SUCCESS_URL, - "failure_url": KASSAI_FAILURE_URL, - "paymentId": unique_payment_id, - } - - sign_string = "|".join(str(data_for_signature[k]) for k in sorted(data_for_signature.keys())) - signature = hmac.new(KASSAI_API_KEY.encode("utf-8"), sign_string.encode("utf-8"), hashlib.sha256).hexdigest() - - data = {**data_for_signature, "signature": signature} - - try: - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=data, timeout=60) as resp: - if resp.status == 200: - try: - resp_json = await resp.json() - if resp_json.get("type") == "success": - payment_url = resp_json.get("location") - if payment_url: - logger.info(f"KassaAI payment URL created for user {tg_id}") - return payment_url - logger.error(f"KassaAI: No location in response: {resp_json}") - return "https://fk.life/" - logger.error(f"KassaAI: Unsuccessful response: {resp_json}") - return "https://fk.life/" - except Exception as e: - logger.error(f"KassaAI: Error parsing JSON response: {e}") - text = await resp.text() - logger.error(f"KassaAI: Response content: {text}") - return "https://fk.life/" - else: - try: - error_json = await resp.json() - logger.error(f"KassaAI API error: status={resp.status}, response={error_json}") - except Exception: - text = await resp.text() - logger.error(f"KassaAI API error: status={resp.status}, non-JSON response: {text}") - return "https://fk.life/" - except Exception as e: - logger.error(f"Error creating KassaAI order: {e}") - return "https://fk.life/" - - -def verify_kassai_signature(data: dict, signature: str) -> bool: - """ - Проверка подписи вебхука FreeKassa (используемой KassaAI): - MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID - """ - 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() - result = signature.upper() == expected_signature.upper() - if not result: - logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}") - logger.error(f"Sign string: {sign_string}") - return result - except Exception as e: - logger.error(f"Ошибка проверки подписи KassaAI: {e}") - return False - - -async def handle_custom_amount_input_kassai_cards( - event: types.Message | types.CallbackQuery, - session: AsyncSession, - pay_button_text: str = PAY_2, - main_menu_text: str = MAIN_MENU, -): - """ - Функция быстрого потока для KassaI Cards - принимает недостающую сумму и формирует платеж картами. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. - """ - if isinstance(event, types.CallbackQuery): - 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 - 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 - else: - message = event - from_user = message.from_user - tg_id = from_user.id - text = message.text - if not text or not text.isdigit(): - await message.answer("Введите корректную сумму числом.") - return - amount = int(text) - if amount <= 0: - await message.answer("Сумма должна быть больше нуля.") - return - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "cards" and m["enable"]), None) - if not method: - await message.answer("❌ Оплата картами KassaAI временно недоступна.") - return - - try: - payment_url = await generate_kassai_payment_link(amount, tg_id, method) - - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], - ] - ) - - language_code = getattr(from_user, "language_code", 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: - logger.error(f"Ошибка при создании платежа KassaAI Cards для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) - - -async def handle_custom_amount_input_kassai_sbp( - event: types.Message | types.CallbackQuery, - session: AsyncSession, - pay_button_text: str = PAY_2, - main_menu_text: str = MAIN_MENU, -): - """ - Функция быстрого потока для KassaI SBP - принимает недостающую сумму и формирует платеж через СБП. - Работает с временными данными из fast_payment_flow для создания/продления/подарка. - """ - if isinstance(event, types.CallbackQuery): - 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 - else: - message = event - from_user = message.from_user - tg_id = from_user.id - text = message.text - if not text or not text.isdigit(): - await message.answer("Введите корректную сумму числом.") - return - amount = int(text) - if amount <= 0: - await message.answer("Сумма должна быть больше нуля.") - return - if amount < 10: - await message.answer("Минимальная сумма для оплаты через СБП — 10 рублей.") - return - method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == "sbp" and m["enable"]), None) - if not method: - await message.answer("❌ Оплата через СБП KassaAI временно недоступна.") - return - - try: - payment_url = await generate_kassai_payment_link(amount, tg_id, method) - - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=pay_button_text, url=payment_url)], - [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], - ] - ) - - language_code = getattr(from_user, "language_code", 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: - logger.error(f"Ошибка при создании платежа KassaAI SBP для пользователя {tg_id}: {e}") - await edit_or_send_message( - target_message=message, - text="Произошла ошибка при создании платежа. Попробуйте позже.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), - ) From 1a6450284dbd3cca42510216dec4ac1cb3c93eff Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:04:27 +0000 Subject: [PATCH 11/13] =?UTF-8?q?FIX=20"=D0=9A=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=203"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/fast_payment_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py index db62fca6..a09dad68 100644 --- a/handlers/payments/fast_payment_flow.py +++ b/handlers/payments/fast_payment_flow.py @@ -39,7 +39,6 @@ async def _run_provider_flow( if not fast_name: return False - # Специальная логика для KassaI - оба метода в одном модуле if up.startswith("KASSAI_"): module_name = "handlers.payments.kassai.handlers" else: From 412a31b938ad68c9ce470ff8cbb42d215a3e8bf5 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:25:42 +0000 Subject: [PATCH 12/13] =?UTF-8?q?FIX=20"Modules=20=D0=B2=20Providers"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь мы можем использовать modules для каждого провайдера, если несколько методов в одной платежке --- handlers/payments/fast_payment_flow.py | 6 ++++-- handlers/payments/providers.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py index a09dad68..9e3df152 100644 --- a/handlers/payments/fast_payment_flow.py +++ b/handlers/payments/fast_payment_flow.py @@ -39,8 +39,10 @@ async def _run_provider_flow( if not fast_name: return False - if up.startswith("KASSAI_"): - module_name = "handlers.payments.kassai.handlers" + # Используем поле module из конфигурации, если есть + module_name_from_config = cfg.get("module") + if module_name_from_config: + module_name = f"handlers.payments.{module_name_from_config}.handlers" else: module_name = f"handlers.payments.{up.lower()}.handlers" diff --git a/handlers/payments/providers.py b/handlers/payments/providers.py index ec3ad55d..f44e7e73 100644 --- a/handlers/payments/providers.py +++ b/handlers/payments/providers.py @@ -21,11 +21,13 @@ PROVIDERS_BASE: Dict[str, dict] = { "currency": "RUB", "value": "pay_kassai_cards", "fast": "handle_custom_amount_input_kassai_cards", + "module": "kassai", }, "KASSAI_SBP": { "currency": "RUB", "value": "pay_kassai_sbp", "fast": "handle_custom_amount_input_kassai_sbp", + "module": "kassai", }, "WATA_RU": { "currency": "RUB", From 284d79dd67c3db41bd3b3c8cf73d9c728dd145a0 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:27:23 +0000 Subject: [PATCH 13/13] =?UTF-8?q?FIX=20"=D0=9A=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=205"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/payments/fast_payment_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py index 9e3df152..349d8b7b 100644 --- a/handlers/payments/fast_payment_flow.py +++ b/handlers/payments/fast_payment_flow.py @@ -39,7 +39,6 @@ async def _run_provider_flow( if not fast_name: return False - # Используем поле module из конфигурации, если есть module_name_from_config = cfg.get("module") if module_name_from_config: module_name = f"handlers.payments.{module_name_from_config}.handlers"