feat: add wata payment provider

This commit is contained in:
3252a8
2026-05-17 23:09:04 +03:00
parent 11429e887e
commit fff7e90e14
26 changed files with 996 additions and 6 deletions
@@ -13,6 +13,7 @@ from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService
from bot.services.wata_service import WataService
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
@@ -65,6 +66,15 @@ def build_core_services(
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
wata_service = WataService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
@@ -101,4 +111,5 @@ def build_core_services(
"lknpd_service": lknpd_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
"wata_service": wata_service,
}
@@ -295,6 +295,60 @@ SETTINGS_MANIFEST: List[SettingField] = [
min=30,
max=4320,
),
# Wata
SettingField("WATA_ENABLED", "bool", "payments", "Enabled", subsection="Wata"),
SettingField(
"WATA_API_TOKEN",
"string",
"payments",
"API token",
subsection="Wata",
secret=True,
),
SettingField(
"WATA_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://api.wata.pro/api/h2h",
subsection="Wata",
),
SettingField("WATA_RETURN_URL", "url", "payments", "Return URL", subsection="Wata"),
SettingField("WATA_FAILED_URL", "url", "payments", "Failed URL", subsection="Wata"),
SettingField(
"WATA_PAYMENT_LINK_TTL_DAYS",
"int",
"payments",
"Payment link lifetime (days)",
"1..30; Wata defaults to 3 days and allows up to 30 days.",
subsection="Wata",
min=1,
max=30,
),
SettingField(
"WATA_WEBHOOK_VERIFY_SIGNATURE",
"bool",
"payments",
"Verify webhook signature",
subsection="Wata",
),
SettingField(
"WATA_PUBLIC_KEY",
"text",
"payments",
"Webhook public key",
"Optional. If empty, the backend fetches it from Wata.",
subsection="Wata",
secret=True,
),
SettingField(
"WATA_TRUSTED_IPS",
"string",
"payments",
"Trusted IPs",
"Comma-separated IP addresses accepted for Wata webhooks.",
subsection="Wata",
),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField(
+7
View File
@@ -41,6 +41,7 @@ def _inject_shared_instances(
"panel_webhook_service",
"platega_service",
"severpay_service",
"wata_service",
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
@@ -96,6 +97,7 @@ async def build_and_start_web_app(
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.platega_service import platega_webhook_route
from bot.services.severpay_service import severpay_webhook_route
from bot.services.wata_service import wata_webhook_route
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
@@ -117,6 +119,11 @@ async def build_and_start_web_app(
app.router.add_post(sp_path, severpay_webhook_route)
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
wata_path = settings.wata_webhook_path
if wata_path.startswith("/"):
app.router.add_post(wata_path, wata_webhook_route)
logging.info(f"Wata webhook route configured at: [POST] {wata_path}")
# YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
+1
View File
@@ -52,6 +52,7 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.wata_service import WataService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
@@ -45,6 +45,7 @@ def create_subscription_webapp_application(
"cryptopay_service",
"platega_service",
"severpay_service",
"wata_service",
"promo_code_service",
"referral_service",
"panel_service",
+84
View File
@@ -708,6 +708,19 @@ async def _create_subscription_payment(
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "wata":
if not settings.WATA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_wata_payment(
request,
session,
user_id,
months,
price,
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"]
if not settings.CRYPTOPAY_ENABLED or not service or not service.configured:
@@ -1118,6 +1131,77 @@ async def _create_severpay_payment(
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_wata_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
*,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
service: WataService = request.app["wata_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_wata",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="wata",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
success, response_data = await service.create_payment_link(
payment_db_id=payment.payment_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
)
payment_url = response_data.get("url") if success else None
provider_id = response_data.get("id")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_id),
payment.status,
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session,
payment.payment_id,
"failed_creation",
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("Wata WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_stars_payment(
request: web.Request,
session: AsyncSession,
@@ -568,6 +568,7 @@ def _serialize_payment_methods(
app: web.Application,
) -> List[Dict[str, Any]]:
labels = {
"wata": "Wata",
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega_sbp": "Platega · СБП",
@@ -605,6 +606,12 @@ def _serialize_payment_methods(
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "wata"
and settings.WATA_ENABLED
and _service_configured(app, "wata_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "yookassa"
and settings.YOOKASSA_ENABLED
+2
View File
@@ -44,6 +44,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_wata",
"pending_cryptopay",
]
status_emoji = (
@@ -67,6 +68,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
"wata": "Wata",
}.get(payment.provider, payment.provider or "Unknown")
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
+1
View File
@@ -215,6 +215,7 @@ async def show_statistics_handler(
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_wata",
"pending_cryptopay",
]
status_emoji = (
@@ -6,6 +6,7 @@ from .payments_platega import router as platega_router
from .payments_severpay import router as severpay_router
from .payments_stars import router as stars_router
from .payments_subscription import router as subscription_selection_router
from .payments_wata import router as wata_router
from .payments_yookassa import router as yookassa_router
router = Router(name="user_subscription_payments_router")
@@ -15,6 +16,7 @@ router.include_router(yookassa_router)
router.include_router(freekassa_router)
router.include_router(platega_router)
router.include_router(severpay_router)
router.include_router(wata_router)
router.include_router(crypto_router)
router.include_router(stars_router)
@@ -0,0 +1,223 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.wata_service import WataService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_wata_router")
@router.callback_query(F.data.startswith("pay_wata:"))
async def pay_wata_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
wata_service: WataService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error("Invalid pay_wata data in callback: %s", callback.data)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_wata",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "wata",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
"Wata: failed to create payment record for user %s: %s",
user_id,
e_db_create,
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=price_rub,
currency=currency_code,
description=payment_description,
)
if success:
payment_link = response_data.get("url")
provider_identifier = response_data.get("id")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
"Wata: failed to store provider payment id for payment %s: %s",
payment_record.payment_id,
e_status,
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
"Wata: failed to display payment link (%s), sending new message.",
e_edit,
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base
in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Wata: payment link created but missing url for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
"Wata: failed to mark payment %s as failed_creation: %s",
payment_record.payment_id,
e_status,
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -360,6 +360,11 @@ def get_payment_method_keyboard(
text=_("pay_with_severpay_button"),
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
)
elif method == "wata" and getattr(settings, "WATA_ENABLED", False):
builder.button(
text=_("pay_with_wata_button"),
callback_data=f"pay_wata:{value_str}:{price}{mode_suffix}",
)
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
builder.button(
text=_("pay_with_sbp_button"),
+1
View File
@@ -200,6 +200,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"referral_service",
"platega_service",
"severpay_service",
"wata_service",
):
await close_service(service_key)
@@ -376,6 +376,7 @@ class NotificationService:
)
provider_emoji = {
"wata": "💳",
"yookassa": "💳",
"freekassa": "💳",
"cryptopay": "",
@@ -12,6 +12,7 @@ class PaymentContextMixin:
"freekassa": "FreeKassa",
"platega": "Platega",
"severpay": "SeverPay",
"wata": "Wata",
"cryptopay": "Crypto Pay",
"telegram_stars": "Telegram Stars",
}
+489
View File
@@ -0,0 +1,489 @@
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class WataService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
self.api_token = settings.WATA_API_TOKEN or ""
self.return_url = settings.WATA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.WATA_FAILED_URL or self.return_url
self.payment_link_ttl_days = settings.WATA_PAYMENT_LINK_TTL_DAYS
self.verify_webhook_signature = settings.WATA_WEBHOOK_VERIFY_SIGNATURE
self._public_key_pem = settings.WATA_PUBLIC_KEY
self._timeout = ClientTimeout(total=20)
self._session: Optional[ClientSession] = None
self.configured: bool = bool(settings.WATA_ENABLED and self.api_token)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@staticmethod
def _format_amount(amount: float) -> Decimal:
return Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _auth_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
async def create_payment_link(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
body: Dict[str, Any] = {
"amount": float(self._format_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
}
try:
async with session.post(
f"{self.base_url}/links",
json=body,
headers=self._auth_headers(),
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("Wata create_payment_link: invalid JSON: %s", response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200:
logging.error(
"Wata create_payment_link: API error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("Wata create_payment_link: request failed.")
return False, {"message": str(exc)}
async def _get_public_key_pem(self) -> Optional[str]:
if self._public_key_pem:
return self._public_key_pem.replace("\\n", "\n")
session = await self._get_session()
try:
async with session.get(f"{self.base_url}/public-key") as response:
if response.status != 200:
logging.error("Wata public key request failed with status %s", response.status)
return None
data = await response.json()
value = data.get("value") if isinstance(data, dict) else None
if isinstance(value, str) and value.strip():
self._public_key_pem = value
return value.replace("\\n", "\n")
except Exception:
logging.exception("Wata public key request failed.")
return None
async def _verify_signature(self, raw_body: bytes, signature_header: str) -> bool:
if not signature_header:
return False
public_key_pem = await self._get_public_key_pem()
if not public_key_pem:
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
signature = base64.b64decode(signature_header)
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA512())
return True
except (InvalidSignature, ValueError, TypeError):
logging.warning("Wata webhook: invalid signature.")
return False
except Exception:
logging.exception("Wata webhook: signature verification failed.")
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.settings.wata_trusted_ips and not ip_in_allowlist(
client_ip, self.settings.wata_trusted_ips
):
logging.warning("Wata webhook denied from unauthorized IP source.")
return web.Response(status=403, text="forbidden")
raw_body = await request.read()
if self.verify_webhook_signature:
signature = request.headers.get("X-Signature", "")
if not await self._verify_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode("utf-8"))
except Exception:
logging.exception("Wata webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip()
status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not status or not (transaction_id or order_id_raw):
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
payment_db_id: Optional[int] = None
if isinstance(order_id_raw, int):
payment_db_id = order_id_raw
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
payment_db_id = int(order_id_raw)
async with self.async_session_factory() as session:
payment = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and transaction_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, transaction_id
)
if not payment:
logging.error(
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
order_id_raw,
transaction_id,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "paid":
return web.Response(text="ok")
if status == "paid":
return await self._process_paid_payment(
session=session,
payment=payment,
transaction_id=transaction_id or str(payment.payment_id),
amount_raw=amount_raw,
currency=str(currency),
)
if status == "declined":
return await self._process_declined_payment(
session=session,
payment=payment,
transaction_id=transaction_id or str(payment.payment_id),
)
logging.warning(
"Wata webhook: unhandled status '%s' for transaction %s",
status,
transaction_id,
)
return web.Response(status=202, text="status_ignored")
async def _process_paid_payment(
self,
*,
session,
payment,
transaction_id: str,
amount_raw: Any,
currency: str,
) -> web.Response:
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if amount_raw is not None:
try:
incoming_amount = self._format_amount(float(amount_raw))
expected_amount = self._format_amount(float(payment.amount))
if incoming_amount != expected_amount:
logging.warning(
"Wata webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
except Exception as exc:
logging.warning(
"Wata webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_units) if sale_base == "subscription" else int(float(payment_units)),
float(payment.amount),
payment.payment_id,
provider="wata",
sale_mode=sale_mode,
traffic_gb=float(payment_units)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_units),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Wata webhook: failed to process payment %s.", transaction_id)
return web.Response(status=500, text="processing_error")
await self._notify_success(
session=session,
payment=payment,
payment_units=payment_units,
sale_base=sale_base,
activation=activation,
referral_bonus=referral_bonus,
currency=currency,
)
return web.Response(text="ok")
async def _process_declined_payment(self, *, session, payment, transaction_id: str):
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Wata webhook: failed to mark payment %s as failed.", transaction_id)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
logging.exception("Wata webhook: failed to notify user about failed payment.")
return web.Response(text="ok")
async def _notify_success(
self,
*,
session,
payment,
payment_units: float,
sale_base: str,
activation: Optional[Dict[str, Any]],
referral_bonus: Optional[Dict[str, Any]],
currency: str,
) -> None:
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings,
raw_config_link,
)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
units_label = (
str(int(payment_units)) if float(payment_units).is_integer() else f"{payment_units:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=units_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username,
with_at=False,
)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_units,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else final_end.strftime("%Y-%m-%d")
if final_end
else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_units,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_units,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("Wata webhook: failed to notify user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=currency,
months=int(payment_units) if sale_base == "subscription" else 0,
traffic_gb=float(payment_units)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="wata",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("Wata webhook: failed to notify admins.")
async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"]
return await service.webhook_route(request)
+77 -1
View File
@@ -62,6 +62,15 @@ class PaymentSettings(BaseModel):
severpay_return_url: Optional[str]
severpay_base_url: str
severpay_lifetime_minutes: Optional[int]
wata_enabled: bool
wata_api_token: Optional[str]
wata_base_url: str
wata_return_url: Optional[str]
wata_failed_url: Optional[str]
wata_payment_link_ttl_days: int
wata_webhook_verify_signature: bool
wata_public_key: Optional[str]
wata_trusted_ips: List[str]
cryptopay_enabled: bool
cryptopay_token: Optional[str]
cryptopay_network: str
@@ -255,11 +264,30 @@ class Settings(BaseSettings):
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
)
WATA_ENABLED: bool = Field(default=False)
WATA_API_TOKEN: Optional[str] = None
WATA_BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
WATA_RETURN_URL: Optional[str] = None
WATA_FAILED_URL: Optional[str] = None
WATA_PAYMENT_LINK_TTL_DAYS: int = Field(
default=3,
description="Payment link lifetime in days (1-30).",
)
WATA_WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
WATA_PUBLIC_KEY: Optional[str] = Field(
default=None,
description="Optional cached Wata RSA public key for webhook signature verification.",
)
WATA_TRUSTED_IPS: str = Field(
default="62.84.126.140,51.250.106.150",
description="Comma-separated Wata webhook IP allowlist.",
)
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
@@ -532,6 +560,15 @@ class Settings(BaseSettings):
severpay_return_url=self.SEVERPAY_RETURN_URL,
severpay_base_url=self.SEVERPAY_BASE_URL,
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
wata_enabled=self.WATA_ENABLED,
wata_api_token=self.WATA_API_TOKEN,
wata_base_url=self.WATA_BASE_URL,
wata_return_url=self.WATA_RETURN_URL,
wata_failed_url=self.WATA_FAILED_URL,
wata_payment_link_ttl_days=self.WATA_PAYMENT_LINK_TTL_DAYS,
wata_webhook_verify_signature=self.WATA_WEBHOOK_VERIFY_SIGNATURE,
wata_public_key=self.WATA_PUBLIC_KEY,
wata_trusted_ips=self.wata_trusted_ips,
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
cryptopay_token=self.CRYPTOPAY_TOKEN,
cryptopay_network=self.CRYPTOPAY_NETWORK,
@@ -719,6 +756,24 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
return None
@computed_field
@property
def wata_webhook_path(self) -> str:
return "/webhook/wata"
@computed_field
@property
def wata_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.wata_webhook_path}"
return None
@computed_field
@property
def wata_trusted_ips(self) -> List[str]:
return _split_csv(self.WATA_TRUSTED_IPS)
@computed_field
@property
def platega_webhook_path(self) -> str:
@@ -948,6 +1003,7 @@ class Settings(BaseSettings):
"platega_sbp",
"platega_crypto",
"severpay",
"wata",
"yookassa",
"stars",
"cryptopay",
@@ -1060,6 +1116,10 @@ class Settings(BaseSettings):
"PLATEGA_RETURN_URL",
"PLATEGA_FAILED_URL",
"SEVERPAY_RETURN_URL",
"WATA_RETURN_URL",
"WATA_FAILED_URL",
"WATA_API_TOKEN",
"WATA_PUBLIC_KEY",
"CRYPT4_REDIRECT_URL",
"PRIVACY_POLICY_URL",
"USER_AGREEMENT_URL",
@@ -1091,6 +1151,17 @@ class Settings(BaseSettings):
return None
return v
@field_validator("WATA_PAYMENT_LINK_TTL_DAYS", mode="before")
@classmethod
def validate_wata_ttl_days(cls, v):
if isinstance(v, str):
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3
return min(30, max(1, value))
# Notification types
LOG_NEW_USERS: bool = Field(
default=True, description="Send notifications for new user registrations"
@@ -1182,6 +1253,11 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." # noqa: E501
)
if _settings_instance.WATA_ENABLED:
if not _settings_instance.WATA_API_TOKEN:
logging.warning(
"CRITICAL: Wata is enabled but WATA_API_TOKEN is missing. Wata payments will not work." # noqa: E501
)
except ValidationError as e:
logging.critical(f"Pydantic validation error while loading settings: {e}")