feat: add wata payment provider
This commit is contained in:
+13
-2
@@ -89,8 +89,9 @@ STARS_ENABLED=True #
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
WATA_ENABLED=False # Turn on Wata
|
||||
# Order of payment methods (top to bottom). Supported: severpay, wata, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,wata,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -140,6 +141,16 @@ SEVERPAY_TOKEN= #
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Wata Payment Gateway Configuration
|
||||
WATA_BASE_URL=https://api.wata.pro/api/h2h # Base API URL (use https://api-sandbox.wata.pro/api/h2h for sandbox)
|
||||
WATA_API_TOKEN= # Bearer token from Wata terminal settings
|
||||
WATA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
WATA_FAILED_URL= # Optional: redirect after failed payment (defaults to return URL)
|
||||
WATA_PAYMENT_LINK_TTL_DAYS=3 # Payment link lifetime in days (1-30)
|
||||
WATA_WEBHOOK_VERIFY_SIGNATURE=True # Verify X-Signature with Wata RSA public key
|
||||
WATA_PUBLIC_KEY= # Optional: cached public key; leave empty to fetch from Wata
|
||||
WATA_TRUSTED_IPS=62.84.126.140,51.250.106.150 # Wata webhook source IP allowlist
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
|
||||
@@ -15,7 +15,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Telegram Stars;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
Для администраторов:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("/"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал и поведение `/start`;
|
||||
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
|
||||
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
|
||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Stars;
|
||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Stars;
|
||||
- пробный период, реферальные бонусы, уведомления, логирование, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||
|
||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||
|
||||
@@ -48,7 +48,7 @@ nano .env
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты через запятую: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. |
|
||||
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты через запятую: `severpay`, `wata`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. |
|
||||
| `YOOKASSA_ENABLED` | Включает YooKassa. |
|
||||
| `YOOKASSA_SHOP_ID` / `YOOKASSA_SECRET_KEY` | Данные магазина YooKassa. |
|
||||
| `YOOKASSA_RETURN_URL` | URL возврата пользователя после оплаты. |
|
||||
@@ -73,6 +73,14 @@ nano .env
|
||||
| `SEVERPAY_BASE_URL` | Базовый URL API SeverPay. |
|
||||
| `SEVERPAY_RETURN_URL` | URL возврата после оплаты. |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | Время жизни платежной ссылки. |
|
||||
| `WATA_ENABLED` | Включает Wata. |
|
||||
| `WATA_API_TOKEN` | Bearer-токен терминала Wata. |
|
||||
| `WATA_BASE_URL` | Базовый URL API Wata (`https://api.wata.pro/api/h2h`, для песочницы `https://api-sandbox.wata.pro/api/h2h`). |
|
||||
| `WATA_RETURN_URL` / `WATA_FAILED_URL` | URL возврата после успешной или неуспешной оплаты. |
|
||||
| `WATA_PAYMENT_LINK_TTL_DAYS` | Время жизни платежной ссылки в днях, от 1 до 30. |
|
||||
| `WATA_WEBHOOK_VERIFY_SIGNATURE` | Проверять `X-Signature` вебхука через RSA/SHA512. |
|
||||
| `WATA_PUBLIC_KEY` | Необязательный публичный ключ Wata для проверки вебхуков; если пусто, backend загрузит его из API. |
|
||||
| `WATA_TRUSTED_IPS` | IP-адреса Wata, с которых принимаются вебхуки. |
|
||||
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
|
||||
| `CRYPTOPAY_TOKEN` | Токен CryptoPay App. |
|
||||
| `CRYPTOPAY_NETWORK` | Сеть: `mainnet` или `testnet`. |
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
if (id.includes("yookassa") || id.includes("card"))
|
||||
return { title: t("pay_with_yookassa_button"), icon: null };
|
||||
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
|
||||
if (id.includes("wata")) return { title: t("pay_with_wata_button"), icon: null };
|
||||
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
|
||||
if (id.includes("cryptopay") || id.includes("crypto"))
|
||||
return { title: t("pay_with_cryptopay_button"), icon: null };
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"pay_with_platega_sbp_button": "🏦 Pay via SBP",
|
||||
"pay_with_platega_crypto_button": "🪙 Pay with crypto",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"pay_with_wata_button": "Wata",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"pay_with_platega_sbp_button": "🏦 Оплата через СБП",
|
||||
"pay_with_platega_crypto_button": "🪙 Оплата криптой",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"pay_with_wata_button": "Wata",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
|
||||
@@ -131,6 +131,7 @@ class BuildServicesWiringTests(unittest.TestCase):
|
||||
"lknpd_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"wata_service",
|
||||
}
|
||||
self.assertEqual(set(services), expected_keys)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ _PROVIDERS_FROM_CALLERS = {
|
||||
"freekassa",
|
||||
"platega",
|
||||
"severpay",
|
||||
"wata",
|
||||
"cryptopay",
|
||||
"telegram_stars",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user