Merge pull request #237 from JustYay/dev

Fix Currency rate and Heleket payment
This commit is contained in:
Vladislav Lisitsyn
2025-09-16 02:30:34 +03:00
committed by GitHub
13 changed files with 428 additions and 47 deletions
+2 -2
View File
@@ -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
+2 -1
View File
@@ -69,7 +69,8 @@ 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))))
pct = Decimal(str(FX_MARKUP)) / Decimal("100")
rate = _q(rate * (Decimal("1") + pct))
cache[code] = (now, rate)
return rate
+5 -1
View File
@@ -39,7 +39,11 @@ async def _run_provider_flow(
if not fast_name:
return False
module_name = f"handlers.payments.{up.lower()}.handlers"
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"
try:
module = importlib.import_module(module_name)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+80
View File
@@ -0,0 +1,80 @@
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")
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
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]
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=[]),
)
@@ -52,7 +52,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 +60,11 @@ 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,
+97
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+137
View File
@@ -0,0 +1,137 @@
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")
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=[]),
)
@@ -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,
@@ -379,21 +378,3 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) ->
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
+87
View File
@@ -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)
-18
View File
@@ -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)
+5 -3
View File
@@ -20,12 +20,14 @@ PROVIDERS_BASE: Dict[str, dict] = {
"KASSAI_CARDS": {
"currency": "RUB",
"value": "pay_kassai_cards",
"fast": None,
"fast": "handle_custom_amount_input_kassai_cards",
"module": "kassai",
},
"KASSAI_SBP": {
"currency": "RUB",
"value": "pay_kassai_sbp",
"fast": None,
"fast": "handle_custom_amount_input_kassai_sbp",
"module": "kassai",
},
"WATA_RU": {
"currency": "RUB",
@@ -45,7 +47,7 @@ PROVIDERS_BASE: Dict[str, dict] = {
"HELEKET": {
"currency": "USD",
"value": "pay_heleket_crypto",
"fast": None,
"fast": "handle_custom_amount_input_heleket",
},
"CRYPTOBOT": {
"currency": "USD",