From fff7e90e149909d9e32f2ab34c23118126018d5a Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 23:09:04 +0300 Subject: [PATCH] feat: add wata payment provider --- .env.example | 15 +- README.md | 2 +- backend/bot/app/factories/build_services.py | 11 + .../bot/app/web/admin_settings_manifest.py | 54 ++ backend/bot/app/web/web_server.py | 7 + backend/bot/app/web/webapp/_runtime.py | 1 + backend/bot/app/web/webapp/application.py | 1 + backend/bot/app/web/webapp/billing.py | 84 +++ backend/bot/app/web/webapp/serializers.py | 7 + backend/bot/handlers/admin/payments.py | 2 + backend/bot/handlers/admin/statistics.py | 1 + .../handlers/user/subscription/payments.py | 2 + .../user/subscription/payments_wata.py | 223 ++++++++ .../bot/keyboards/inline/user_keyboards.py | 5 + backend/bot/main_bot.py | 1 + backend/bot/services/notification_service.py | 1 + .../subscription_service_impl/payments.py | 1 + backend/bot/services/wata_service.py | 489 ++++++++++++++++++ backend/config/settings.py | 78 ++- docs/admin.md | 2 +- docs/configuration.md | 10 +- .../patterns/webapp/PaymentMethodGrid.svelte | 1 + locales/en.json | 1 + locales/ru.json | 1 + tests/test_build_services_wiring.py | 1 + tests/test_provider_labels.py | 1 + 26 files changed, 996 insertions(+), 6 deletions(-) create mode 100644 backend/bot/handlers/user/subscription/payments_wata.py create mode 100644 backend/bot/services/wata_service.py diff --git a/.env.example b/.env.example index 07c836b..3ddb35a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 21e354d..8c62d8e 100644 --- a/README.md +++ b/README.md @@ -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`. Для администраторов: diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py index 6d7f4a3..8445c47 100644 --- a/backend/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -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, } diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 739f99a..d31029f 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -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( diff --git a/backend/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py index 6e00c33..561164f 100644 --- a/backend/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -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("/"): diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index fddbe4e..1553662 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -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 diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index df988af..2409a5b 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -45,6 +45,7 @@ def create_subscription_webapp_application( "cryptopay_service", "platega_service", "severpay_service", + "wata_service", "promo_code_service", "referral_service", "panel_service", diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 5a4fe94..d09b067 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -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, diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index bc0c158..742784a 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -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 diff --git a/backend/bot/handlers/admin/payments.py b/backend/bot/handlers/admin/payments.py index 32b483d..ad32147 100644 --- a/backend/bot/handlers/admin/payments.py +++ b/backend/bot/handlers/admin/payments.py @@ -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] diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py index b8e0446..2deca6d 100644 --- a/backend/bot/handlers/admin/statistics.py +++ b/backend/bot/handlers/admin/statistics.py @@ -215,6 +215,7 @@ async def show_statistics_handler( "pending_freekassa", "pending_platega", "pending_severpay", + "pending_wata", "pending_cryptopay", ] status_emoji = ( diff --git a/backend/bot/handlers/user/subscription/payments.py b/backend/bot/handlers/user/subscription/payments.py index da1507a..1c64e50 100644 --- a/backend/bot/handlers/user/subscription/payments.py +++ b/backend/bot/handlers/user/subscription/payments.py @@ -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) diff --git a/backend/bot/handlers/user/subscription/payments_wata.py b/backend/bot/handlers/user/subscription/payments_wata.py new file mode 100644 index 0000000..21b4ad7 --- /dev/null +++ b/backend/bot/handlers/user/subscription/payments_wata.py @@ -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 diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 1396a39..9253e88 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -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"), diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index 06c67f4..b7086d6 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -200,6 +200,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "referral_service", "platega_service", "severpay_service", + "wata_service", ): await close_service(service_key) diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index c17588f..d6ca7e4 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -376,6 +376,7 @@ class NotificationService: ) provider_emoji = { + "wata": "💳", "yookassa": "💳", "freekassa": "💳", "cryptopay": "₿", diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index d9f2df6..ca81d04 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -12,6 +12,7 @@ class PaymentContextMixin: "freekassa": "FreeKassa", "platega": "Platega", "severpay": "SeverPay", + "wata": "Wata", "cryptopay": "Crypto Pay", "telegram_stars": "Telegram Stars", } diff --git a/backend/bot/services/wata_service.py b/backend/bot/services/wata_service.py new file mode 100644 index 0000000..c86b296 --- /dev/null +++ b/backend/bot/services/wata_service.py @@ -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) diff --git a/backend/config/settings.py b/backend/config/settings.py index e845a3c..5abb702 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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}") diff --git a/docs/admin.md b/docs/admin.md index 51ef001..e90956f 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -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` или коде. diff --git a/docs/configuration.md b/docs/configuration.md index ff20bf3..4a3003b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`. | diff --git a/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte index 8ddaca8..f0ef412 100644 --- a/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte +++ b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte @@ -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 }; diff --git a/locales/en.json b/locales/en.json index a0ab36e..b03f805 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/ru.json b/locales/ru.json index ed230ae..d65a0df 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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", diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py index 3e194c6..f7703ed 100644 --- a/tests/test_build_services_wiring.py +++ b/tests/test_build_services_wiring.py @@ -131,6 +131,7 @@ class BuildServicesWiringTests(unittest.TestCase): "lknpd_service", "platega_service", "severpay_service", + "wata_service", } self.assertEqual(set(services), expected_keys) diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py index ffdd5b8..95dbeb5 100644 --- a/tests/test_provider_labels.py +++ b/tests/test_provider_labels.py @@ -25,6 +25,7 @@ _PROVIDERS_FROM_CALLERS = { "freekassa", "platega", "severpay", + "wata", "cryptopay", "telegram_stars", }