Merge pull request #217 from TrackLine/dev

Add wata.pro payments
This commit is contained in:
Vladislav Lisitsyn
2025-07-17 20:45:20 +03:00
committed by GitHub
7 changed files with 417 additions and 1 deletions
+3
View File
@@ -90,3 +90,6 @@ PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
STARS_BOT = "🤖 Бот для покупки звезд"
WATA_RU = "🇷🇺 WATA: Карты РФ"
WATA_SBP = "🏦 WATA: СБП"
WATA_INT = "🌍 WATA: Международные карты"
+27
View File
@@ -16,6 +16,9 @@ from config import (
STARS_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
WATA_RU_ENABLE,
WATA_SBP_ENABLE,
WATA_INT_ENABLE,
)
from database import get_last_payments
from database.models import User
@@ -30,6 +33,9 @@ from handlers.buttons import (
STARS,
YOOKASSA,
YOOMONEY,
WATA_RU,
WATA_SBP,
WATA_INT,
)
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
from handlers.payments.freekassa_pay import process_callback_pay_freekassa
@@ -37,6 +43,7 @@ from handlers.payments.robokassa_pay import process_callback_pay_robokassa
from handlers.payments.stars_pay import process_callback_pay_stars
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
from handlers.payments.wata import process_callback_pay_wata
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
@@ -63,6 +70,8 @@ async def handle_pay(
payment_handlers.append(process_callback_pay_robokassa)
if FREEKASSA_ENABLE:
payment_handlers.append(process_callback_pay_freekassa)
if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE:
payment_handlers.append(process_callback_pay_wata)
if len(payment_handlers) == 1:
await callback_query.answer()
@@ -82,6 +91,12 @@ async def handle_pay(
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
if FREEKASSA_ENABLE:
builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa"))
if WATA_RU_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru"))
if WATA_SBP_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_SBP, callback_data="pay_wata_sbp"))
if WATA_INT_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_INT, callback_data="pay_wata_int"))
if DONATIONS_ENABLE:
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
@@ -150,3 +165,15 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
media_path=None,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "pay_wata_ru")
async def handle_pay_wata_ru(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="ru")
@router.callback_query(F.data == "pay_wata_sbp")
async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="sbp")
@router.callback_query(F.data == "pay_wata_int")
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")
+2
View File
@@ -18,6 +18,7 @@ from .robokassa_pay import router as robokassa_router
from .stars_pay import router as stars_router
from .yookassa_pay import router as yookassa_router
from .yoomoney_pay import router as yoomoney_router
from .wata import router as wata_router
router = Router(name="payments_main_router")
@@ -34,4 +35,5 @@ if CRYPTO_BOT_ENABLE:
if STARS_ENABLE:
router.include_router(stars_router)
router.include_router(wata_router)
router.include_router(gift_router)
+308
View File
@@ -0,0 +1,308 @@
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 datetime import datetime, timedelta
from sqlalchemy import select, and_
from config import (
WATA_RU_ENABLE, WATA_RU_TOKEN,
WATA_SBP_ENABLE, WATA_SBP_TOKEN,
WATA_INT_ENABLE, WATA_INT_TOKEN,
REDIRECT_LINK,
FAIL_REDIRECT_LINK,
)
from database import (
add_payment,
add_user,
async_session_maker,
check_user_exists,
get_key_count,
get_temporary_data,
update_balance,
Payment
)
from handlers.buttons import BACK, PAY_2, WATA_RU, WATA_SBP, WATA_INT
from handlers.texts import (
WATA_RU_DESCRIPTION, WATA_SBP_DESCRIPTION, WATA_INT_DESCRIPTION,
WATA_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS, WATA_PAYMENT_TITLE
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
class ReplenishBalanceWataState(StatesGroup):
choosing_cassa = State()
choosing_amount = State()
waiting_for_payment_confirmation = State()
entering_custom_amount = State()
WATA_CASSA_CONFIG = [
{"enable": WATA_RU_ENABLE, "token": WATA_RU_TOKEN, "name": "ru", "button": WATA_RU, "desc": WATA_RU_DESCRIPTION},
{"enable": WATA_SBP_ENABLE, "token": WATA_SBP_TOKEN, "name": "sbp", "button": WATA_SBP, "desc": WATA_SBP_DESCRIPTION},
{"enable": WATA_INT_ENABLE, "token": WATA_INT_TOKEN, "name": "int", "button": WATA_INT, "desc": WATA_INT_DESCRIPTION},
]
@router.callback_query(F.data == "pay_wata")
async def process_callback_pay_wata(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, cassa_name: str = None):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated WATA payment.")
if cassa_name:
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name and c["enable"]), None)
if not cassa:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранная касса недоступна.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
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'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
)
)
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"wata_custom_amount|{cassa_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text=cassa["desc"],
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id, wata_cassa=cassa_name)
await state.set_state(ReplenishBalanceWataState.choosing_amount)
return
builder = InlineKeyboardBuilder()
for cassa in WATA_CASSA_CONFIG:
if cassa["enable"]:
builder.row(InlineKeyboardButton(text=cassa["button"], callback_data=f'wata_cassa|{cassa["name"]}'))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text="Выберите способ оплаты через WATA:",
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.set_state(ReplenishBalanceWataState.choosing_cassa)
@router.callback_query(F.data.startswith("wata_cassa|"))
async def process_cassa_selection(callback_query: types.CallbackQuery, state: FSMContext):
cassa_name = callback_query.data.split("|")[1]
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
if not cassa or not cassa["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранная касса недоступна.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(wata_cassa=cassa_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'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'wata_amount|{cassa_name}|{PAYMENT_OPTIONS[i]["callback_data"]}',
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_wata"))
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text=cassa["desc"],
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.set_state(ReplenishBalanceWataState.choosing_amount)
@router.callback_query(F.data.startswith("wata_custom_amount|"))
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext):
cassa_name = callback_query.data.split("|")[1]
await state.update_data(wata_cassa=cassa_name)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_wata_{cassa_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(ReplenishBalanceWataState.entering_custom_amount)
@router.message(ReplenishBalanceWataState.entering_custom_amount)
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
data = await state.get_data()
cassa_name = data.get("wata_cassa")
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
if not cassa or not cassa["enable"]:
await edit_or_send_message(
target_message=message,
text="Ошибка: выбранная касса недоступна.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
try:
amount = int(message.text.strip())
if amount <= 0:
raise ValueError
# Проверка для SBP: минимум 50 рублей
if cassa_name == "sbp" and amount < 50:
await edit_or_send_message(
target_message=message,
text="Минимальная сумма для оплаты через СБП — 50 рублей.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
except Exception:
await edit_or_send_message(
target_message=message,
text="Некорректная сумма. Введите целое число больше 0.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(amount=amount)
payment_url = await generate_wata_payment_link(amount, message.chat.id, cassa)
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="pay_wata")],
]
)
await edit_or_send_message(
target_message=message,
text=WATA_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
await state.set_state(ReplenishBalanceWataState.waiting_for_payment_confirmation)
@router.callback_query(F.data.startswith("wata_amount|"))
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
parts = callback_query.data.split("|")
cassa_name = parts[1]
amount_str = parts[-1] # всегда последняя часть — сумма
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
if not cassa or not cassa["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранная касса недоступна.",
reply_markup=types.InlineKeyboardMarkup(),
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=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(amount=amount)
payment_url = await generate_wata_payment_link(amount, callback_query.message.chat.id, cassa)
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="pay_wata")],
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=WATA_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
await state.set_state(ReplenishBalanceWataState.waiting_for_payment_confirmation)
async def generate_wata_payment_link(amount, tg_id, cassa):
url = "https://api.wata.pro/api/h2h/links"
headers = {
"Authorization": f"Bearer {cassa['token']}",
"Content-Type": "application/json",
}
data = {
"amount": float(amount),
"currency": "RUB",
"orderId": str(tg_id),
"orderDescription": WATA_PAYMENT_TITLE,
"successUrl": f"{REDIRECT_LINK}",
"failUrl": f"{FAIL_REDIRECT_LINK}",
}
if cassa["name"] == "int":
import json
async def get_usd_rate():
url = "https://api.exchangerate.host/latest?base=RUB&symbols=USD"
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=10) as resp:
data = await resp.json()
return data["rates"]["USD"]
usd_rate = await get_usd_rate()
rub_per_usd = 1 / usd_rate
rub_per_usd_plus_5 = rub_per_usd + 5
new_usd_rate = 1 / rub_per_usd_plus_5
amount_usd = round(float(amount) * new_usd_rate, 2)
data["amount"] = amount_usd
data["currency"] = "USD"
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()
except Exception:
text = await resp.text()
logger.error(f"Ошибка при разборе JSON ответа WATA: статус={resp.status}, ответ={text}")
return "https://wata.pro/"
if "url" in resp_json:
return resp_json["url"]
else:
logger.error(f"Ответ WATA без url: {resp_json}")
return "https://wata.pro/"
else:
try:
error_json = await resp.json()
logger.error(f"Ошибка WATA API: статус={resp.status}, ответ={error_json}")
except Exception:
text = await resp.text()
logger.error(f"Ошибка WATA API: статус={resp.status}, не-JSON ответ: {text}")
return "https://wata.pro/"
+3 -1
View File
@@ -52,4 +52,6 @@ psycopg2-binary
psutil
uvicorn
fastapi
python-dateutil
python-dateutil
aiohttp>=3.8.0
cryptography>=41.0.0
+4
View File
@@ -4,7 +4,11 @@ import bot
from config import TBLOCKER_WEBHOOK_PATH
from .tblocker import tblocker_webhook
from .wata_payment import wata_payment_webhook
WATA_WEBHOOK_PATH = "/wata/webhook"
async def register_web_routes(router: UrlDispatcher) -> None:
router.add_post(TBLOCKER_WEBHOOK_PATH, tblocker_webhook)
router.add_post(WATA_WEBHOOK_PATH, wata_payment_webhook)
+70
View File
@@ -0,0 +1,70 @@
import base64
import aiohttp
from aiohttp import web
import json
import logging
from database import async_session_maker, update_balance, add_payment
from handlers.payments.utils import send_payment_success_notification
from config import WATA_RU_TOKEN, WATA_SBP_TOKEN, WATA_INT_TOKEN
from logger import logger
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
PUBLIC_KEY_URL = "https://api.wata.pro/api/h2h/public-key"
async def get_wata_public_key():
async with aiohttp.ClientSession() as session:
async with session.get(PUBLIC_KEY_URL) as resp:
data = await resp.json()
return data["value"].encode()
async def verify_signature(raw_json: bytes, signature: str, public_key_pem: bytes) -> bool:
try:
public_key = serialization.load_pem_public_key(public_key_pem, backend=default_backend())
signature_bytes = base64.b64decode(signature)
public_key.verify(
signature_bytes,
raw_json,
padding.PKCS1v15(),
hashes.SHA512()
)
return True
except Exception as e:
logger.error(f"Ошибка проверки подписи WATA: {e}")
return False
async def wata_payment_webhook(request: web.Request):
try:
raw_json = await request.read()
data = json.loads(raw_json)
signature = request.headers.get("X-Signature")
if not signature:
logger.error("Нет подписи X-Signature в заголовке!")
return web.Response(status=400)
public_key_pem = await get_wata_public_key()
if not await verify_signature(raw_json, signature, public_key_pem):
logger.error("Подпись WATA не прошла проверку!")
return web.Response(status=400)
logger.info(f"WATA webhook: {json.dumps(data, ensure_ascii=False)}")
logger.info(f"transactionId={data.get('transactionId')}, status={data.get('transactionStatus')}, orderId={data.get('orderId')}, amount={data.get('amount')}, currency={data.get('currency')}, errorCode={data.get('errorCode')}, errorDescription={data.get('errorDescription')}")
if data.get("transactionStatus") == "Paid":
tg_id = data.get("orderId")
amount = data.get("amount")
if not tg_id or not amount:
logger.error(f"WATA: отсутствует orderId или amount: {data}")
return web.Response(status=400)
async with async_session_maker() as session:
await update_balance(session, int(tg_id), float(amount))
await send_payment_success_notification(tg_id, float(amount), session)
await add_payment(session, int(tg_id), float(amount), "wata")
logger.info(f"✅ WATA: баланс пополнен для пользователя {tg_id} на {amount}")
elif data.get("transactionStatus") == "Declined":
logger.warning(f"WATA: транзакция отклонена: {data}")
else:
logger.warning(f"WATA: неизвестный статус транзакции: {data.get('transactionStatus')}")
return web.Response(status=200)
except Exception as e:
logger.error(f"Ошибка в webhook WATA: {e}")
return web.Response(status=500)