From 51c9c8b4f021fed11118adc5e7b889529d7e48f6 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 18 May 2026 10:29:00 +0300 Subject: [PATCH] refactor: payments providers --- .env.example | 52 + backend/bot/app/factories/build_services.py | 88 +- .../bot/app/web/admin_settings_manifest.py | 65 +- backend/bot/app/web/web_server.py | 65 +- backend/bot/app/web/webapp/_runtime.py | 7 - backend/bot/app/web/webapp/application.py | 9 +- backend/bot/app/web/webapp/billing.py | 634 +---- backend/bot/app/web/webapp/serializers.py | 71 +- backend/bot/handlers/admin/payments.py | 25 +- backend/bot/handlers/admin/statistics.py | 12 +- backend/bot/handlers/user/payment.py | 800 ------ .../handlers/user/subscription/__init__.py | 3 +- .../user/subscription/payment_methods.py | 550 ---- .../handlers/user/subscription/payments.py | 18 +- .../user/subscription/payments_crypto.py | 128 - .../user/subscription/payments_freekassa.py | 244 -- .../user/subscription/payments_platega.py | 261 -- .../user/subscription/payments_severpay.py | 221 -- .../user/subscription/payments_stars.py | 148 - .../user/subscription/payments_wata.py | 223 -- .../user/subscription/payments_yookassa.py | 842 ------ .../bot/keyboards/inline/user_keyboards.py | 62 +- backend/bot/main_bot.py | 10 +- backend/bot/payment_providers/__init__.py | 37 + backend/bot/payment_providers/base.py | 122 + .../cryptopay.py} | 318 ++- backend/bot/payment_providers/freekassa.py | 501 ++++ backend/bot/payment_providers/platega.py | 529 ++++ backend/bot/payment_providers/registry.py | 211 ++ backend/bot/payment_providers/severpay.py | 444 +++ .../bot/payment_providers/shared/__init__.py | 128 + .../bot/payment_providers/shared/callbacks.py | 322 +++ .../bot/payment_providers/shared/common.py | 247 ++ .../payment_providers/shared/http_client.py | 95 + .../bot/payment_providers/shared/success.py | 383 +++ .../bot/payment_providers/shared/webapp.py | 71 + .../bot/payment_providers/shared/webhooks.py | 69 + backend/bot/payment_providers/stars.py | 369 +++ backend/bot/payment_providers/wata.py | 450 +++ backend/bot/payment_providers/yookassa.py | 2510 +++++++++++++++++ backend/bot/services/freekassa_service.py | 466 --- backend/bot/services/notification_service.py | 15 +- backend/bot/services/platega_service.py | 421 --- backend/bot/services/severpay_service.py | 444 --- backend/bot/services/stars_service.py | 240 -- .../subscription_service_impl/payments.py | 10 +- backend/bot/services/wata_service.py | 489 ---- backend/bot/services/yookassa_service.py | 268 -- backend/config/settings.py | 48 + backend/main_worker.py | 8 +- docs/admin.md | 4 +- docs/configuration.md | 2 + .../src/admin/sections/SettingsSection.svelte | 103 +- .../patterns/webapp/PaymentMethodGrid.svelte | 33 +- frontend/src/lib/components/ui/icons.js | 1 + frontend/src/lib/webapp/previewMock.js | 8 +- frontend/src/styles/admin.css | 75 + tests/test_auto_renew_wiring.py | 2 +- tests/test_build_services_wiring.py | 2 +- tests/test_payment_provider_registry.py | 291 ++ tests/test_security.py | 6 +- tests/test_settings.py | 21 + tests/test_webapp_assets.py | 22 + 63 files changed, 7480 insertions(+), 6843 deletions(-) delete mode 100644 backend/bot/handlers/user/payment.py delete mode 100644 backend/bot/handlers/user/subscription/payment_methods.py delete mode 100644 backend/bot/handlers/user/subscription/payments_crypto.py delete mode 100644 backend/bot/handlers/user/subscription/payments_freekassa.py delete mode 100644 backend/bot/handlers/user/subscription/payments_platega.py delete mode 100644 backend/bot/handlers/user/subscription/payments_severpay.py delete mode 100644 backend/bot/handlers/user/subscription/payments_stars.py delete mode 100644 backend/bot/handlers/user/subscription/payments_wata.py delete mode 100644 backend/bot/handlers/user/subscription/payments_yookassa.py create mode 100644 backend/bot/payment_providers/__init__.py create mode 100644 backend/bot/payment_providers/base.py rename backend/bot/{services/crypto_pay_service.py => payment_providers/cryptopay.py} (51%) create mode 100644 backend/bot/payment_providers/freekassa.py create mode 100644 backend/bot/payment_providers/platega.py create mode 100644 backend/bot/payment_providers/registry.py create mode 100644 backend/bot/payment_providers/severpay.py create mode 100644 backend/bot/payment_providers/shared/__init__.py create mode 100644 backend/bot/payment_providers/shared/callbacks.py create mode 100644 backend/bot/payment_providers/shared/common.py create mode 100644 backend/bot/payment_providers/shared/http_client.py create mode 100644 backend/bot/payment_providers/shared/success.py create mode 100644 backend/bot/payment_providers/shared/webapp.py create mode 100644 backend/bot/payment_providers/shared/webhooks.py create mode 100644 backend/bot/payment_providers/stars.py create mode 100644 backend/bot/payment_providers/wata.py create mode 100644 backend/bot/payment_providers/yookassa.py delete mode 100644 backend/bot/services/freekassa_service.py delete mode 100644 backend/bot/services/platega_service.py delete mode 100644 backend/bot/services/severpay_service.py delete mode 100644 backend/bot/services/stars_service.py delete mode 100644 backend/bot/services/wata_service.py delete mode 100644 backend/bot/services/yookassa_service.py create mode 100644 tests/test_payment_provider_registry.py diff --git a/.env.example b/.env.example index 3ddb35a..f0f4c84 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,58 @@ WATA_ENABLED=False # # 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 +# Payment button presentation overrides (all optional; empty = provider defaults) +# Text supports per-language overrides: *_LABEL_RU and *_LABEL_EN. Legacy *_LABEL applies to all languages if per-language values are empty. +# Available WebApp icons are exported from frontend/src/lib/components/ui/icons.js, e.g. CreditCard, Smartphone, Bitcoin, Sparkles. +PAYMENT_YOOKASSA_WEBAPP_LABEL_RU= # WebApp payment button text for YooKassa (Russian) +PAYMENT_YOOKASSA_WEBAPP_LABEL_EN= # WebApp payment button text for YooKassa (English) +PAYMENT_YOOKASSA_WEBAPP_ICON=CreditCard # WebApp payment button icon for YooKassa +PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for YooKassa (Russian) +PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for YooKassa (English) +PAYMENT_YOOKASSA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for YooKassa +PAYMENT_FREEKASSA_WEBAPP_LABEL_RU= # WebApp payment button text for FreeKassa (Russian) +PAYMENT_FREEKASSA_WEBAPP_LABEL_EN= # WebApp payment button text for FreeKassa (English) +PAYMENT_FREEKASSA_WEBAPP_ICON=Smartphone # WebApp payment button icon for FreeKassa +PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for FreeKassa (Russian) +PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for FreeKassa (English) +PAYMENT_FREEKASSA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for FreeKassa +PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_RU= # WebApp payment button text for Platega SBP (Russian) +PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_EN= # WebApp payment button text for Platega SBP (English) +PAYMENT_PLATEGA_SBP_WEBAPP_ICON=CreditCard # WebApp payment button icon for Platega SBP +PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Platega SBP (Russian) +PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Platega SBP (English) +PAYMENT_PLATEGA_SBP_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Platega SBP +PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU= # WebApp payment button text for Platega crypto (Russian) +PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_EN= # WebApp payment button text for Platega crypto (English) +PAYMENT_PLATEGA_CRYPTO_WEBAPP_ICON=Bitcoin # WebApp payment button icon for Platega crypto +PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Platega crypto (Russian) +PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Platega crypto (English) +PAYMENT_PLATEGA_CRYPTO_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Platega crypto +PAYMENT_SEVERPAY_WEBAPP_LABEL_RU= # WebApp payment button text for SeverPay (Russian) +PAYMENT_SEVERPAY_WEBAPP_LABEL_EN= # WebApp payment button text for SeverPay (English) +PAYMENT_SEVERPAY_WEBAPP_ICON=CreditCard # WebApp payment button icon for SeverPay +PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU= # Telegram bot payment button text for SeverPay (Russian) +PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN= # Telegram bot payment button text for SeverPay (English) +PAYMENT_SEVERPAY_TELEGRAM_EMOJI= # Telegram bot payment button emoji for SeverPay +PAYMENT_WATA_WEBAPP_LABEL_RU= # WebApp payment button text for Wata (Russian) +PAYMENT_WATA_WEBAPP_LABEL_EN= # WebApp payment button text for Wata (English) +PAYMENT_WATA_WEBAPP_ICON=WalletCards # WebApp payment button icon for Wata +PAYMENT_WATA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Wata (Russian) +PAYMENT_WATA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Wata (English) +PAYMENT_WATA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Wata +PAYMENT_STARS_WEBAPP_LABEL_RU= # WebApp payment button text for Telegram Stars (Russian) +PAYMENT_STARS_WEBAPP_LABEL_EN= # WebApp payment button text for Telegram Stars (English) +PAYMENT_STARS_WEBAPP_ICON=Sparkles # WebApp payment button icon for Telegram Stars +PAYMENT_STARS_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Telegram Stars (Russian) +PAYMENT_STARS_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Telegram Stars (English) +PAYMENT_STARS_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Telegram Stars +PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU= # WebApp payment button text for CryptoPay (Russian) +PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN= # WebApp payment button text for CryptoPay (English) +PAYMENT_CRYPTOPAY_WEBAPP_ICON=Bitcoin # WebApp payment button icon for CryptoPay +PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU= # Telegram bot payment button text for CryptoPay (Russian) +PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN= # Telegram bot payment button text for CryptoPay (English) +PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI= # Telegram bot payment button emoji for CryptoPay + # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py index 8445c47..9526f93 100644 --- a/backend/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -2,19 +2,13 @@ from aiogram import Bot from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.freekassa_service import FreeKassaService +from bot.payment_providers import ServiceFactoryContext, build_provider_services from bot.services.lknpd_service import LknpdService from bot.services.panel_api_service import PanelApiService from bot.services.panel_webhook_service import PanelWebhookService -from bot.services.platega_service import PlategaService 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.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 @@ -29,61 +23,19 @@ def build_core_services( subscription_service = SubscriptionService(settings, panel_service, bot, i18n) referral_service = ReferralService(settings, subscription_service, bot, i18n) promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n) - stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service) - cryptopay_service = CryptoPayService( - settings.CRYPTOPAY_TOKEN, - settings.CRYPTOPAY_NETWORK, - bot, - settings, - i18n, - async_session_factory, - subscription_service, - referral_service, - ) - freekassa_service = FreeKassaService( - bot=bot, - settings=settings, - i18n=i18n, - async_session_factory=async_session_factory, - subscription_service=subscription_service, - referral_service=referral_service, - ) - platega_service = PlategaService( - 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, - ) - severpay_service = SeverPayService( - 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, - ) - 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 ) - yookassa_service = YooKassaService( - shop_id=settings.YOOKASSA_SHOP_ID, - secret_key=settings.YOOKASSA_SECRET_KEY, - configured_return_url=settings.YOOKASSA_RETURN_URL, - bot_username_for_default_return=bot_username_for_default_return, - settings_obj=settings, + payment_services = build_provider_services( + ServiceFactoryContext( + settings=settings, + bot=bot, + async_session_factory=async_session_factory, + i18n=i18n, + bot_username_for_default_return=bot_username_for_default_return, + subscription_service=subscription_service, + referral_service=referral_service, + ) ) lknpd_service = LknpdService( settings.LKNPD_INN, @@ -91,25 +43,17 @@ def build_core_services( api_url=settings.LKNPD_API_URL, ) - # Wire services that depend on each other. These attachments are critical - # for auto-renew (subscription_service.yookassa_service) and for the panel - # webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors — - # silent wiring failures previously caused auto-renew to disappear. - subscription_service.yookassa_service = yookassa_service + # These attachments are critical for auto-renew and panel pre-expiry hooks. + subscription_service.yookassa_service = payment_services.get("yookassa_service") panel_webhook_service.subscription_service = subscription_service - return { + services = { "panel_service": panel_service, "subscription_service": subscription_service, "referral_service": referral_service, "promo_code_service": promo_code_service, - "stars_service": stars_service, - "cryptopay_service": cryptopay_service, - "freekassa_service": freekassa_service, "panel_webhook_service": panel_webhook_service, - "yookassa_service": yookassa_service, "lknpd_service": lknpd_service, - "platega_service": platega_service, - "severpay_service": severpay_service, - "wata_service": wata_service, } + services.update(payment_services) + return services diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index d31029f..3475588 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -15,7 +15,7 @@ from typing import Any, List, Optional, Tuple @dataclass(frozen=True) class SettingField: key: str - type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret" + type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" section: str label: str description: str = "" @@ -30,6 +30,61 @@ class SettingField: i18n_description_key: Optional[str] = None +def _payment_presentation_fields(method_key: str, subsection: str) -> List[SettingField]: + prefix = f"PAYMENT_{method_key}" + return [ + SettingField( + f"{prefix}_WEBAPP_LABEL_RU", + "string", + "payments", + "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", + subsection=subsection, + ), + SettingField( + f"{prefix}_WEBAPP_LABEL_EN", + "string", + "payments", + "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", + subsection=subsection, + ), + SettingField( + f"{prefix}_WEBAPP_ICON", + "icon", + "payments", + "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + subsection=subsection, + ), + SettingField( + f"{prefix}_TELEGRAM_LABEL_RU", + "string", + "payments", + "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", + subsection=subsection, + ), + SettingField( + f"{prefix}_TELEGRAM_LABEL_EN", + "string", + "payments", + "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", + subsection=subsection, + ), + SettingField( + f"{prefix}_TELEGRAM_EMOJI", + "string", + "payments", + "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + subsection=subsection, + placeholder="💳", + ), + ] + + SETTINGS_MANIFEST: List[SettingField] = [ # ─── General ──────────────────────────────────────────────────── SettingField( @@ -136,6 +191,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"), + *_payment_presentation_fields("STARS", "Telegram Stars"), SettingField( "PAYMENT_METHODS_ORDER", "string", @@ -187,6 +243,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Принудительная привязка карты", subsection="YooKassa", ), + *_payment_presentation_fields("YOOKASSA", "YooKassa"), # FreeKassa SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"), SettingField( @@ -243,6 +300,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Через запятую — IP-адреса, с которых принимаются нотификации", subsection="FreeKassa", ), + *_payment_presentation_fields("FREEKASSA", "FreeKassa"), # Platega SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"), SettingField( @@ -270,6 +328,8 @@ SETTINGS_MANIFEST: List[SettingField] = [ ), SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"), SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"), + *_payment_presentation_fields("PLATEGA_SBP", "Platega SBP"), + *_payment_presentation_fields("PLATEGA_CRYPTO", "Platega Crypto"), # SeverPay SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"), SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"), @@ -295,6 +355,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ min=30, max=4320, ), + *_payment_presentation_fields("SEVERPAY", "SeverPay"), # Wata SettingField("WATA_ENABLED", "bool", "payments", "Enabled", subsection="Wata"), SettingField( @@ -349,6 +410,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Comma-separated IP addresses accepted for Wata webhooks.", subsection="Wata", ), + *_payment_presentation_fields("WATA", "Wata"), # CryptoPay SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"), SettingField( @@ -373,6 +435,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay" ), + *_payment_presentation_fields("CRYPTOPAY", "CryptoPay"), # ─── Trial ───────────────────────────────────────────────────── SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"), SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0), diff --git a/backend/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py index 561164f..2b1ae96 100644 --- a/backend/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati from aiohttp import web from sqlalchemy.orm import sessionmaker +from bot.payment_providers import iter_provider_specs, iter_service_keys from config.settings import Settings @@ -29,20 +30,15 @@ def _inject_shared_instances( app["settings"] = settings app["async_session_factory"] = async_session_factory app["i18n"] = dp.get("i18n_instance") - for key in ( - "yookassa_service", - "lknpd_service", + shared_keys = [ "subscription_service", "referral_service", "panel_service", - "stars_service", - "freekassa_service", - "cryptopay_service", "panel_webhook_service", - "platega_service", - "severpay_service", - "wata_service", - ): + "lknpd_service", + *iter_service_keys(), + ] + for key in shared_keys: if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore app[key] = dp.workflow_data[key] # type: ignore @@ -91,44 +87,21 @@ async def build_and_start_web_app( f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501 ) - from bot.handlers.user.payment import yookassa_webhook_route - from bot.services.crypto_pay_service import cryptopay_webhook_route - from bot.services.freekassa_service import freekassa_webhook_route 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("/"): - app.router.add_post(cp_path, cryptopay_webhook_route) - logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") - - fk_path = settings.freekassa_webhook_path - if fk_path.startswith("/"): - app.router.add_post(fk_path, freekassa_webhook_route) - logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}") - - pg_path = settings.platega_webhook_path - if pg_path.startswith("/"): - app.router.add_post(pg_path, platega_webhook_route) - logging.info(f"Platega webhook route configured at: [POST] {pg_path}") - - sp_path = settings.severpay_webhook_path - if sp_path.startswith("/"): - 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("/"): - app.router.add_post(yk_path, yookassa_webhook_route) - logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}") + registered_webhook_paths: set[str] = set() + for spec in iter_provider_specs(): + webhook_route = spec.load_webhook_route() + if not spec.webhook_path or not webhook_route: + continue + if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL: + continue + path = spec.webhook_path(settings) + if not path or not path.startswith("/") or path in registered_webhook_paths: + continue + registered_webhook_paths.add(path) + app.router.add_post(path, webhook_route) + logging.info("%s webhook route configured at: [POST] %s", spec.label, path) panel_path = settings.panel_webhook_path if panel_path.startswith("/"): diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 1553662..33bf47b 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit from aiogram import Bot, Dispatcher -from aiogram.types import LabeledPrice from aiohttp import ClientSession, ClientTimeout, web from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator from sqlalchemy.ext.asyncio import AsyncSession @@ -43,17 +42,11 @@ from bot.app.web.webapp_auth import ( verify_webapp_session_token, ) from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key -from bot.services.crypto_pay_service import CryptoPayService from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.email_templates import render_account_merged -from bot.services.freekassa_service import FreeKassaService -from bot.services.platega_service import PlategaService 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 from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index 2409a5b..33b66ee 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -38,17 +38,14 @@ def create_subscription_webapp_application( app.on_startup.append(_startup) app.on_shutdown.append(_shutdown) + from bot.payment_providers import iter_service_keys + for key in ( "subscription_service", - "yookassa_service", - "freekassa_service", - "cryptopay_service", - "platega_service", - "severpay_service", - "wata_service", "promo_code_service", "referral_service", "panel_service", + *iter_service_keys(), ): if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] app[key] = dp.workflow_data[key] # type: ignore[index] diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index d09b067..c8ef1a6 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -651,625 +651,27 @@ async def _create_subscription_payment( else _payment_description(int(months), lang) ) - if method == "yookassa": - if not settings.YOOKASSA_ENABLED: + from bot.payment_providers import WebAppPaymentContext, get_provider_spec + + provider_spec = get_provider_spec(method) + if provider_spec and provider_spec.create_webapp_payment: + if not provider_spec.is_enabled(settings): return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_yookassa_payment( - request, - session, - user_id, - months, - price, - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method == "freekassa": - if not settings.FREEKASSA_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_freekassa_payment( - request, - session, - user_id, - months, - price, - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method in ("platega", "platega_sbp", "platega_crypto"): - if not settings.PLATEGA_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_platega_payment( - request, - session, - user_id, - months, - price, - description, - variant=method, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method == "severpay": - if not settings.SEVERPAY_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_severpay_payment( - request, - session, - user_id, - months, - price, - description, - 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: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - url = await service.create_invoice( - session=session, - user_id=user_id, - months=months, - amount=price, - description=description, - sale_mode=sale_mode, - url_kind="web", - ) - if not url: - return _json_error(502, "payment_failed", "Failed to create payment") - return web.json_response( - {"ok": True, "action": "open_link", "payment_url": url, "payment_id": None} - ) - if method == "stars": - if not settings.STARS_ENABLED or stars_price is None: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_stars_payment( - request, - session, - user_id, - months, - int(stars_price), - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, + return await provider_spec.create_webapp_payment( + WebAppPaymentContext( + request=request, + session=session, + user_id=user_id, + method=method, + months=months, + price=price, + stars_price=stars_price, + description=description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) ) return _json_error(400, "payment_unavailable", "Payment method unavailable") -async def _create_base_payment_record( - session: AsyncSession, - *, - user_id: int, - amount: float, - currency: str, - status: str, - description: str, - months: int, - provider: str, - sale_mode: Optional[str] = None, - tariff_key: Optional[str] = None, - purchased_gb: Optional[float] = None, - purchased_hwid_devices: Optional[int] = None, -) -> Payment: - payment = await payment_dal.create_payment_record( - session, - { - "user_id": user_id, - "amount": amount, - "currency": currency, - "status": status, - "description": description, - "subscription_duration_months": months, - "provider": provider, - "sale_mode": sale_mode, - "tariff_key": tariff_key, - "purchased_gb": purchased_gb, - "purchased_hwid_devices": purchased_hwid_devices, - }, - ) - await session.commit() - return payment - - -async def _create_yookassa_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: YooKassaService = request.app["yookassa_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="RUB", - status="pending_yookassa", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="yookassa", - 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, - ) - metadata = { - "user_id": str(user_id), - "subscription_months": str( - int(float(months)) if not traffic_sale and not hwid_devices_sale else 0 - ), - "payment_db_id": str(payment.payment_id), - "sale_mode": sale_mode, - "source": "webapp", - } - if traffic_sale: - metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) - if hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(months))) - if _sale_mode_tariff_key(sale_mode): - metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode) - response = await service.create_payment( - amount=price, - currency="RUB", - description=description, - metadata=metadata, - receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, - save_payment_method=bool( - settings.yookassa_autopayments_active - and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING - ), - ) - payment_url = response.get("confirmation_url") if response else None - 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") - - await payment_dal.update_payment_status_by_db_id( - session, - payment.payment_id, - response.get("status", "pending"), - yk_payment_id=response.get("id"), - ) - await session.commit() - 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("YooKassa WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_freekassa_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: - request.app["settings"] - service: FreeKassaService = request.app["freekassa_service"] - if not service or not service.configured or not service.payment_method_id: - 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=service.default_currency, - status="pending_freekassa", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="freekassa", - 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_order( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months, - amount=price, - currency=service.default_currency, - payment_method_id=service.payment_method_id, - ip_address=service.server_ip, - extra_params={"us_method": service.payment_method_id}, - ) - payment_url = response_data.get("location") if success else None - provider_id = response_data.get("orderHash") or response_data.get("orderId") - 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("FreeKassa WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_platega_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - price: float, - description: str, - variant: str = "platega_sbp", - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - service: PlategaService = request.app["platega_service"] - if not service or not service.configured: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if variant == "platega_crypto": - if not settings.PLATEGA_CRYPTO_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - platega_method_id = settings.PLATEGA_CRYPTO_METHOD - else: - if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - platega_method_id = settings.platega_sbp_method_resolved - - 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_platega", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="platega", - 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, - ) - months_for_provider = ( - int(float(months)) if not traffic_sale else int(float(traffic_gb or months)) - ) - payload = json.dumps( - { - "payment_db_id": payment.payment_id, - "user_id": user_id, - "months": months_for_provider if not traffic_sale else 0, - "sale_mode": sale_mode, - "traffic_gb": _format_number_for_payload(traffic_gb or months) - if traffic_sale - else None, - "hwid_devices": int(float(months)) if hwid_devices_sale else None, - "source": "webapp", - "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", - } - ) - success, response_data = await service.create_transaction( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months_for_provider, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - description=description, - payload=payload, - payment_method=platega_method_id, - ) - payment_url = ( - ( - response_data.get("redirect") - or response_data.get("url") - or response_data.get("paymentUrl") - ) - if success - else None - ) - provider_id = response_data.get("transactionId") or response_data.get("id") - if provider_id: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - str(provider_id), - str(response_data.get("status", 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("Platega WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_severpay_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: SeverPayService = request.app["severpay_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_severpay", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="severpay", - 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( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - description=description, - ) - payment_url = ( - ( - response_data.get("url") - or response_data.get("payment_url") - or response_data.get("paymentUrl") - ) - if success - else None - ) - provider_id = response_data.get("id") or response_data.get("uid") - 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("SeverPay WebApp payment failed") - 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, - user_id: int, - months: Any, - stars_price: int, - description: str, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - bot: Bot = request.app["bot"] - 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=float(stars_price), - currency="XTR", - status="pending_stars", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="telegram_stars", - 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, - ) - payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months - payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}" - prices = [LabeledPrice(label=description, amount=stars_price)] - create_invoice_link = getattr(bot, "create_invoice_link", None) - if callable(create_invoice_link): - invoice_url = await create_invoice_link( - title=description, - description=description, - payload=payload, - # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. - provider_token="", - currency="XTR", - prices=prices, - ) - return web.json_response( - { - "ok": True, - "action": "open_invoice", - "payment_url": invoice_url, - "payment_id": payment.payment_id, - } - ) - - await bot.send_invoice( - chat_id=user_id, - title=description, - description=description, - payload=payload, - provider_token="", - currency="XTR", - prices=prices, - ) - return web.json_response( - { - "ok": True, - "action": "invoice_sent", - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("Stars WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create invoice") diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 742784a..a6e91a3 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -97,7 +97,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A traffic_packages=cached["traffic_packages"], stars_traffic_packages=cached["stars_traffic_packages"], ), - "payment_methods": _serialize_payment_methods(settings, request.app), + "payment_methods": _serialize_payment_methods(settings, request.app, lang), "themes_catalog": public_themes_catalog_payload( settings.webapp_themes_catalog, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", @@ -566,66 +566,23 @@ def _serialize_tariff_change_target( def _serialize_payment_methods( settings: Settings, app: web.Application, + lang: str = "ru", ) -> List[Dict[str, Any]]: - labels = { - "wata": "Wata", - "severpay": "SeverPay", - "freekassa": "FreeKassa / СБП", - "platega_sbp": "Platega · СБП", - "platega_crypto": "Platega · Crypto", - "yookassa": "Банковская карта", - "stars": "Telegram Stars", - "cryptopay": "CryptoPay", - } + from bot.payment_providers import get_provider_spec, resolve_provider_presentation + methods: List[Dict[str, Any]] = [] for method in settings.payment_methods_order: method = method.lower() - if ( - method == "severpay" - and settings.SEVERPAY_ENABLED - and _service_configured(app, "severpay_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "freekassa" - and settings.FREEKASSA_ENABLED - and _service_configured(app, "freekassa_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "platega_sbp" - and settings.PLATEGA_ENABLED - and settings.PLATEGA_SBP_ENABLED - and _service_configured(app, "platega_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "platega_crypto" - and settings.PLATEGA_ENABLED - and settings.PLATEGA_CRYPTO_ENABLED - 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 - and _service_configured(app, "yookassa_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif method == "stars" and settings.STARS_ENABLED: - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "cryptopay" - and settings.CRYPTOPAY_ENABLED - and _service_configured(app, "cryptopay_service") - ): - methods.append({"id": method, "name": labels[method]}) + spec = get_provider_spec(method) + if spec and spec.is_visible(settings, app): + presentation = resolve_provider_presentation(spec, settings, language=lang) + methods.append( + { + "id": method, + "name": presentation.webapp_label, + "icon": presentation.webapp_icon, + } + ) return methods diff --git a/backend/bot/handlers/admin/payments.py b/backend/bot/handlers/admin/payments.py index ad32147..ad59dce 100644 --- a/backend/bot/handlers/admin/payments.py +++ b/backend/bot/handlers/admin/payments.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n +from bot.payment_providers import pending_statuses, provider_label_map from config.settings import Settings from db.dal import payment_dal from db.models import Payment @@ -38,19 +39,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S """Format single payment info as text.""" _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) - pending_statuses = [ - "pending", - "pending_yookassa", - "pending_freekassa", - "pending_platega", - "pending_severpay", - "pending_wata", - "pending_cryptopay", - ] status_emoji = ( "✅" if payment.status == "succeeded" - else ("⏳" if payment.status in pending_statuses else "❌") + else ("⏳" if payment.status in pending_statuses() else "❌") ) user_info = f"User {payment.user_id}" @@ -61,15 +53,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A" - provider_text = { - "yookassa": "YooKassa", - "telegram_stars": "Telegram Stars", - "cryptopay": "CryptoPay", - "freekassa": "FreeKassa", - "severpay": "SeverPay", - "platega": "Platega", - "wata": "Wata", - }.get(payment.provider, payment.provider or "Unknown") + provider_text = provider_label_map(settings, lang).get( + payment.provider, + payment.provider or "Unknown", + ) sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0] traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py index 2deca6d..888970f 100644 --- a/backend/bot/handlers/admin/statistics.py +++ b/backend/bot/handlers/admin/statistics.py @@ -10,6 +10,7 @@ from bot.keyboards.inline.admin_keyboards import ( get_back_to_user_management_keyboard, ) from bot.middlewares.i18n import JsonI18n +from bot.payment_providers import pending_statuses from bot.services.panel_api_service import PanelApiService from config.settings import Settings from db.dal import panel_sync_dal, payment_dal, user_dal @@ -209,20 +210,11 @@ async def show_statistics_handler( if last_payments_models: stats_text_parts.append(f"\n{_('admin_stats_recent_payments_header')}") for payment in last_payments_models: - pending_statuses = [ - "pending", - "pending_yookassa", - "pending_freekassa", - "pending_platega", - "pending_severpay", - "pending_wata", - "pending_cryptopay", - ] status_emoji = ( "✅" if payment.status == "succeeded" else "⏳" - if payment.status in pending_statuses + if payment.status in pending_statuses() else "❌" ) diff --git a/backend/bot/handlers/user/payment.py b/backend/bot/handlers/user/payment.py deleted file mode 100644 index 7b21aee..0000000 --- a/backend/bot/handlers/user/payment.py +++ /dev/null @@ -1,800 +0,0 @@ -import asyncio -import json -import logging -from datetime import datetime, timezone -from typing import Optional - -from aiogram import Bot -from aiohttp import web -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import sessionmaker -from yookassa.domain.notification import WebhookNotification - -from bot.infra.webhook_queue import enqueue_webhook_event -from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from bot.middlewares.i18n import JsonI18n -from bot.services.lknpd_service import LknpdService -from bot.services.notification_service import NotificationService -from bot.services.panel_api_service import PanelApiService -from bot.services.referral_service import ReferralService -from bot.services.subscription_service import SubscriptionService -from bot.services.yookassa_service import YooKassaService -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_billing_dal, user_dal - -payment_processing_lock = asyncio.Lock() - -YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded" -YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled" -YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture" -YOOKASSA_WEBHOOK_ALLOWED_IPS = [ - "185.71.76.0/27", - "185.71.77.0/27", - "77.75.153.0/25", - "77.75.156.11", - "77.75.156.35", - "77.75.154.128/25", - "2a02:5180::/32", -] - - -async def process_successful_payment( - session: AsyncSession, - bot: Bot, - payment_info_from_webhook: dict, - i18n: JsonI18n, - settings: Settings, - panel_service: PanelApiService, - subscription_service: SubscriptionService, - referral_service: ReferralService, - lknpd_service: Optional[LknpdService] = None, -): - metadata = payment_info_from_webhook.get("metadata", {}) - user_id_str = metadata.get("user_id") - subscription_months_str = metadata.get("subscription_months") - traffic_gb_str = metadata.get("traffic_gb") - sale_mode = metadata.get("sale_mode") or ( - "traffic" if settings.traffic_sale_mode else "subscription" - ) - sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0] - promo_code_id_str = metadata.get("promo_code_id") - payment_db_id_str = metadata.get("payment_db_id") - auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id") - - # For auto-renew payments, payment_db_id may be absent. In that case, - # we will create/ensure a payment record idempotently using provider payment id. - if ( - not user_id_str - or (not subscription_months_str and not traffic_gb_str) - or (not payment_db_id_str and not auto_renew_subscription_id_str) - ): - logging.error( - f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501 - ) - return - - db_user = None - try: - user_id = int(user_id_str) - subscription_months = float(subscription_months_str or 0) - traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months - payment_db_id = ( - int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None - ) - is_auto_renew = bool( - auto_renew_subscription_id_str - and not payment_db_id - and sale_mode_base == "subscription" - ) - promo_code_id = ( - int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None - ) - - amount_data = payment_info_from_webhook.get("amount", {}) - months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 - payment_value = float(amount_data.get("value", 0.0)) - yk_payment_id_from_hook = payment_info_from_webhook.get("id") - - payment_record = None - # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists - if payment_db_id is None and auto_renew_subscription_id_str: - try: - if not yk_payment_id_from_hook: - logging.error( - "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501 - ) - return - from db.dal import payment_dal as _payment_dal - - payment_record = await _payment_dal.get_payment_by_provider_payment_id( - session, yk_payment_id_from_hook - ) - if not payment_record: - payment_record = await _payment_dal.ensure_payment_with_provider_id( - session, - user_id=user_id, - amount=payment_value, - currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL), - months=months_for_record or 1, - description=payment_info_from_webhook.get("description") - or f"Auto-renewal for {months_for_record or subscription_months} months", - provider="yookassa", - provider_payment_id=yk_payment_id_from_hook, - ) - payment_db_id = payment_record.payment_id - except Exception as e_ensure: - logging.error( - f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501 - exc_info=True, - ) - return - elif payment_db_id is not None: - payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id) - if not payment_record: - logging.error( - f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}." - ) - return - - if payment_record and payment_record.status == "succeeded": - logging.info( - f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501 - ) - return - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user: - logging.error( - f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501 - ) - - await payment_dal.update_payment_status_by_db_id( - session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id") - ) - - return - - except (TypeError, ValueError) as e: - logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}") - - if payment_db_id_str and payment_db_id_str.isdigit(): - try: - await payment_dal.update_payment_status_by_db_id( - session, - int(payment_db_id_str), - "failed_metadata_error", - payment_info_from_webhook.get("id"), - ) - except Exception as e_upd: - logging.error(f"Failed to update payment status after metadata error: {e_upd}") - return - - try: - yk_payment_id_from_hook = payment_info_from_webhook.get("id") - payment_before_update = None - if payment_db_id is not None: - payment_before_update = await payment_dal.get_payment_by_db_id( - session, - payment_db_id, - ) - should_send_lknpd_receipt = bool( - lknpd_service - and lknpd_service.configured - and payment_info_from_webhook.get("paid") is True - and payment_info_from_webhook.get("status") == "succeeded" - and payment_before_update - and payment_before_update.status != "succeeded" - ) - # Try to capture and save payment method for future charges if available - try: - payment_method = payment_info_from_webhook.get("payment_method") - if ( - settings.yookassa_autopayments_active - and isinstance(payment_method, dict) - and payment_method.get("saved", False) - ): - pm_id = payment_method.get("id") - pm_type = payment_method.get("type") - title = payment_method.get("title") - card = payment_method.get("card") or {} - account_number = payment_method.get("account_number") or payment_method.get( - "account" - ) - display_network = None - display_last4 = None - # Build generic display for various instrument types - if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: - display_network = card.get("card_type") or title or "Card" - display_last4 = card.get("last4") - elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: - # Normalize wallet display name to avoid leaking full account from title - display_network = "YooMoney" - if isinstance(account_number, str) and len(account_number) >= 4: - display_last4 = account_number[-4:] - else: - display_last4 = None - else: - # Wallets, SBP, etc. — use provided title/type; no last4 - display_network = title or (pm_type.upper() if pm_type else "Payment method") - display_last4 = None - - await user_billing_dal.upsert_yk_payment_method( - session, - user_id=user_id, - payment_method_id=pm_id, - card_last4=display_last4, - card_network=display_network, - ) - try: - await user_billing_dal.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=pm_id, - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=True, - ) - except Exception: - logging.exception("Failed to persist multi-card YooKassa method from webhook") - except Exception: - logging.exception("Failed to persist YooKassa payment method from webhook") - months_for_activation = ( - int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb) - ) - activation_details = await subscription_service.activate_subscription( - session, - user_id, - months_for_activation, - payment_value, - payment_db_id, - promo_code_id_from_payment=promo_code_id, - provider="yookassa", - sale_mode=sale_mode, - traffic_gb=traffic_amount_gb - if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - ) - - if not activation_details or not activation_details.get("end_date"): - logging.error( - f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501 - ) - raise Exception(f"Subscription Error: Failed to activate for user {user_id}") - - updated_payment_record = await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=payment_db_id, - new_status=payment_info_from_webhook.get("status", "succeeded"), - yk_payment_id=yk_payment_id_from_hook, - ) - if not updated_payment_record: - logging.error( - f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501 - ) - raise Exception(f"DB Error: Could not update payment record {payment_db_id}") - - base_subscription_end_date = activation_details["end_date"] - final_end_date_for_user = base_subscription_end_date - applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0) - - referral_bonus_info = None - if sale_mode_base == "subscription": - referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - months_for_activation or int(subscription_months) or 1, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) - applied_referee_bonus_days_from_referral: Optional[int] = None - if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"): - final_end_date_for_user = referral_bonus_info["referee_new_end_date"] - applied_referee_bonus_days_from_referral = referral_bonus_info.get( - "referee_bonus_applied_days" - ) - - # Use user's DB language for all user-facing messages - user_lang = ( - db_user.language_code - if db_user and db_user.language_code - else settings.DEFAULT_LANGUAGE - ) - _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) - - traffic_label = ( - str(int(traffic_amount_gb)) - if float(traffic_amount_gb).is_integer() - else f"{traffic_amount_gb:g}" - ) - if should_send_lknpd_receipt: - receipt_item_name = payment_info_from_webhook.get("description") - if not receipt_item_name: - if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) - else: - receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format( - months=int(subscription_months) - ) - try: - await lknpd_service.create_income_receipt( - item_name=receipt_item_name, - amount=payment_value, - quantity=1.0, - operation_time=datetime.now(timezone.utc), - ) - except Exception: - logging.exception( - "Failed to send LKNPD receipt for payment %s", - yk_payment_id_from_hook, - ) - config_link_display, connect_button_url = await prepare_config_links( - settings, activation_details.get("subscription_url") if activation_details else None - ) - config_link_text = config_link_display or _("config_link_not_available") - # For auto-renew charges, avoid re-sending config link; send concise message - if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user: - details_message = _( - "yookassa_auto_renewal", - months=int(subscription_months), - end_date=final_end_date_for_user.strftime("%Y-%m-%d"), - ) - details_markup = None - elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - details_message = _( - "payment_successful_traffic_full", - traffic_gb=traffic_label, - end_date=final_end_date_for_user.strftime("%Y-%m-%d") - if final_end_date_for_user - else "—", - config_link=config_link_text, - ) - details_markup = get_connect_and_main_keyboard( - user_lang, - i18n, - settings, - config_link_display, - connect_button_url=connect_button_url, - preserve_message=True, - ) - else: - if applied_referee_bonus_days_from_referral and final_end_date_for_user: - 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 - ) - - details_message = _( - "payment_successful_with_referral_bonus_full", - months=int(subscription_months), - base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"), - bonus_days=applied_referee_bonus_days_from_referral, - final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"), - inviter_name=inviter_name_display, - config_link=config_link_text, - ) - elif applied_promo_bonus_days > 0 and final_end_date_for_user: - details_message = _( - "payment_successful_with_promo_full", - months=int(subscription_months), - bonus_days=applied_promo_bonus_days, - end_date=final_end_date_for_user.strftime("%Y-%m-%d"), - config_link=config_link_text, - ) - elif final_end_date_for_user: - details_message = _( - "payment_successful_full", - months=int(subscription_months), - end_date=final_end_date_for_user.strftime("%Y-%m-%d"), - config_link=config_link_text, - ) - else: - logging.error( - f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501 - ) - details_message = _("payment_successful_error_details") - - details_markup = get_connect_and_main_keyboard( - user_lang, - i18n, - settings, - config_link_display, - connect_button_url=connect_button_url, - preserve_message=True, - ) - try: - await bot.send_message( - user_id, - details_message, - reply_markup=details_markup, - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as e_notify: - logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}") - - # Send notification about payment - try: - notification_service = NotificationService(bot, settings, i18n) - user = await user_dal.get_user_by_id(session, user_id) - tariff_for_log = None - if payment_before_update and getattr(payment_before_update, "tariff_key", None): - tariff_for_log = payment_before_update.tariff_key - elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None): - tariff_for_log = updated_payment_record.tariff_key - elif payment_record and getattr(payment_record, "tariff_key", None): - tariff_for_log = payment_record.tariff_key - await notification_service.notify_payment_received( - user_id=user_id, - amount=payment_value, - currency=settings.DEFAULT_CURRENCY_SYMBOL, - months=int(subscription_months) if sale_mode_base == "subscription" else 0, - payment_provider="yookassa", # This is specifically for YooKassa webhook - username=user.username if user else None, - traffic_gb=traffic_amount_gb - if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - traffic_is_premium=sale_mode_base == "premium_topup", - tariff_key=tariff_for_log, - ) - except Exception as e: - logging.error(f"Failed to send payment notification: {e}") - - except Exception as e_process: - logging.error( - f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501 - exc_info=True, - ) - - raise - - -async def process_cancelled_payment( - session: AsyncSession, - bot: Bot, - payment_info_from_webhook: dict, - i18n: JsonI18n, - settings: Settings, -): - - metadata = payment_info_from_webhook.get("metadata", {}) - user_id_str = metadata.get("user_id") - payment_db_id_str = metadata.get("payment_db_id") - - if not user_id_str or not payment_db_id_str: - logging.warning( - f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}" - ) - return - try: - user_id = int(user_id_str) - payment_db_id = int(payment_db_id_str) - except ValueError: - logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}") - return - - try: - updated_payment = await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=payment_db_id, - new_status=payment_info_from_webhook.get("status", "canceled"), - yk_payment_id=payment_info_from_webhook.get("id"), - ) - - if updated_payment: - logging.info( - f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501 - ) - else: - logging.warning( - f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501 - ) - - db_user = await user_dal.get_user_by_id(session, user_id) - user_lang = settings.DEFAULT_LANGUAGE - if db_user and db_user.language_code: - user_lang = db_user.language_code - - _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) - await bot.send_message(user_id, _("payment_failed")) - - except Exception as e_process_cancel: - logging.error( - f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501 - exc_info=True, - ) - raise - - -async def yookassa_webhook_route(request: web.Request): - - try: - bot: Bot = request.app["bot"] - i18n_instance: JsonI18n = request.app["i18n"] - settings: Settings = request.app["settings"] - panel_service: PanelApiService = request.app["panel_service"] - subscription_service: SubscriptionService = request.app["subscription_service"] - referral_service: ReferralService = request.app["referral_service"] - lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service") - async_session_factory: sessionmaker = request.app["async_session_factory"] - except KeyError: - logging.exception("KeyError accessing app context in yookassa_webhook_route.") - return web.Response(status=500, text="Internal Server Error: Missing app context component") - - client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies) - if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS): - logging.warning("YooKassa webhook denied from unauthorized IP source.") - return web.Response(status=403) - - try: - event_json = await request.json() - - notification_object = WebhookNotification(event_json) - payment_data_from_notification = notification_object.object - - logging.info( - f"YooKassa Webhook Parsed: Event='{notification_object.event}', " - f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501 - ) - - if ( - not payment_data_from_notification - or not hasattr(payment_data_from_notification, "metadata") - or payment_data_from_notification.metadata is None - ): - logging.error( - f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501 - ) - return web.Response(status=200, text="ok_error_no_metadata") - - # Safely extract payment_method details (SDK objects may not have to_dict) - pm_obj = getattr(payment_data_from_notification, "payment_method", None) - pm_dict = None - if pm_obj is not None: - try: - card_obj = getattr(pm_obj, "card", None) - pm_dict = { - "id": getattr(pm_obj, "id", None), - "type": getattr(pm_obj, "type", None), - "saved": bool(getattr(pm_obj, "saved", False)), - "title": getattr(pm_obj, "title", None), - "account_number": ( - getattr(pm_obj, "account_number", None) - if hasattr(pm_obj, "account_number") - else ( - getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None - ) - ), - "card": ( - { - "first6": getattr(card_obj, "first6", None), - "last4": getattr(card_obj, "last4", None), - "expiry_month": getattr(card_obj, "expiry_month", None), - "expiry_year": getattr(card_obj, "expiry_year", None), - "card_type": getattr(card_obj, "card_type", None), - } - if card_obj is not None - else None - ), - } - except Exception: - logging.exception("Failed to serialize YooKassa payment_method from webhook") - pm_dict = None - - payment_dict_for_processing = { - "id": str(payment_data_from_notification.id), - "status": str(payment_data_from_notification.status), - "paid": bool(payment_data_from_notification.paid), - "amount": { - "value": str(payment_data_from_notification.amount.value), - "currency": str(payment_data_from_notification.amount.currency), - } - if payment_data_from_notification.amount - else {}, - "metadata": dict(payment_data_from_notification.metadata), - "description": str(payment_data_from_notification.description) - if payment_data_from_notification.description - else None, - "payment_method": pm_dict, - } - - if notification_object.event in { - YOOKASSA_EVENT_PAYMENT_SUCCEEDED, - YOOKASSA_EVENT_PAYMENT_CANCELED, - }: - queued = await enqueue_webhook_event( - settings, - "yookassa", - { - "event": notification_object.event, - "payment": payment_dict_for_processing, - }, - event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}", - ) - if queued: - return web.Response(status=200, text="queued") - - async with payment_processing_lock: - async with async_session_factory() as session: - try: - if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: - if ( - payment_dict_for_processing.get("paid") - and payment_dict_for_processing.get("status") == "succeeded" - ): - await process_successful_payment( - session, - bot, - payment_dict_for_processing, - i18n_instance, - settings, - panel_service, - subscription_service, - referral_service, - lknpd_service, - ) - await session.commit() - else: - logging.warning( - f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501 - f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501 - f"paid='{payment_dict_for_processing.get('paid')}'" - ) - elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: - await process_cancelled_payment( - session, bot, payment_dict_for_processing, i18n_instance, settings - ) - await session.commit() - elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: - # Bind-only flow: save method and cancel auth if metadata has bind_only - metadata = payment_dict_for_processing.get("metadata", {}) or {} - if ( - settings.yookassa_autopayments_active - and metadata.get("bind_only") == "1" - ): - try: - user_id_str = metadata.get("user_id") - if user_id_str and user_id_str.isdigit(): - user_id = int(user_id_str) - payment_method = payment_dict_for_processing.get( - "payment_method" - ) - if isinstance(payment_method, dict) and payment_method.get( - "id" - ): - pm_type = payment_method.get("type") - title = payment_method.get("title") - card = payment_method.get("card") or {} - account_number = payment_method.get( - "account_number" - ) or payment_method.get("account") - display_network = None - display_last4 = None - if (pm_type or "").lower() in { - "bank_card", - "bank-card", - "card", - }: - display_network = ( - card.get("card_type") or title or "Card" - ) - display_last4 = card.get("last4") - elif (pm_type or "").lower() in { - "yoo_money", - "yoomoney", - "yoo-money", - "wallet", - }: - # Normalize wallet display name to avoid leaking full account from title # noqa: E501 - display_network = "YooMoney" - if ( - isinstance(account_number, str) - and len(account_number) >= 4 - ): - display_last4 = account_number[-4:] - else: - display_last4 = None - else: - display_network = title or ( - pm_type.upper() if pm_type else "Payment method" - ) - display_last4 = None - await user_billing_dal.upsert_yk_payment_method( - session, - user_id=user_id, - payment_method_id=payment_method.get("id"), - card_last4=display_last4, - card_network=display_network, - ) - await session.commit() - # Save multi-card entry and mark default if first - try: - from db.dal import user_billing_dal as ub - - await ub.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=payment_method.get("id"), - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=True, - ) - await session.commit() - except Exception: - await session.rollback() - # Notify user about successful binding with Back button - try: - # Use user's DB language for bind success notification - i18n_lang = settings.DEFAULT_LANGUAGE - from db.dal import user_dal - - db_user = await user_dal.get_user_by_id( - session, user_id - ) - if db_user and db_user.language_code: - i18n_lang = db_user.language_code - _ = lambda key, **kwargs: i18n_instance.gettext( - i18n_lang, key, **kwargs - ) - from bot.keyboards.inline.user_keyboards import ( - get_back_to_payment_methods_keyboard, - ) - - await bot.send_message( - chat_id=user_id, - text=_("payment_method_bound_success"), - reply_markup=get_back_to_payment_methods_keyboard( - i18n_lang, i18n_instance - ), - ) - except Exception: - pass - # Attempt to cancel the authorization to avoid charge hold - try: - yk: YooKassaService = request.app.get( - "yookassa_service" - ) - if yk: - await yk.cancel_payment( - payment_dict_for_processing.get("id") - ) - except Exception: - logging.exception( - "Failed to cancel bind-only payment auth" - ) - except Exception: - logging.exception( - "Failed to handle bind-only waiting_for_capture webhook" - ) - except Exception: - await session.rollback() - logging.exception( - "Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501 - notification_object.event, - payment_dict_for_processing.get("id"), - ) - return web.Response(status=500, text="internal_processing_error") - - return web.Response(status=200, text="ok") - - except json.JSONDecodeError: - logging.error("YooKassa Webhook: Invalid JSON received.") - return web.Response(status=400, text="bad_request_invalid_json") - except Exception: - logging.exception("YooKassa Webhook general processing error.") - return web.Response(status=500, text="internal_error") diff --git a/backend/bot/handlers/user/subscription/__init__.py b/backend/bot/handlers/user/subscription/__init__.py index 67e743a..afc9949 100644 --- a/backend/bot/handlers/user/subscription/__init__.py +++ b/backend/bot/handlers/user/subscription/__init__.py @@ -1,13 +1,12 @@ from aiogram import Router -from . import core, payment_methods, payments +from . import core, payments router = Router(name="user_subscription_router") # Include sub-routers router.include_router(core.router) router.include_router(payments.router) -router.include_router(payment_methods.router) # Re-export commonly used entrypoints for backward compatibility from .core import ( # noqa: E402,F401 diff --git a/backend/bot/handlers/user/subscription/payment_methods.py b/backend/bot/handlers/user/subscription/payment_methods.py deleted file mode 100644 index 62c2e31..0000000 --- a/backend/bot/handlers/user/subscription/payment_methods.py +++ /dev/null @@ -1,550 +0,0 @@ -from typing import List, Optional - -from aiogram import F, Router, types -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from bot.keyboards.inline.user_keyboards import ( - get_bind_url_keyboard, - get_payment_method_delete_confirm_keyboard, - get_payment_method_details_keyboard, - get_payment_methods_list_keyboard, -) -from bot.middlewares.i18n import JsonI18n -from bot.services.yookassa_service import YooKassaService -from config.settings import Settings -from db.dal import user_billing_dal -from db.models import Payment - -router = Router(name="user_subscription_payment_methods_router") - - -@router.callback_query(F.data == "pm:manage") -async def payment_methods_manage( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - from db.dal.user_billing_dal import list_user_payment_methods - - get_text = _ - methods = await list_user_payment_methods(session, callback.from_user.id) - cards: List[tuple] = [] - - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return get_text("payment_method_wallet_title", last4=l4) - return get_text("payment_method_wallet_title", last4="****") - if last4: - network_name = network or get_text("payment_network_card") - return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic") - return get_text("payment_method_generic_title", network=network_name) - - for m in methods: - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - - text = get_text("payment_methods_title") - if not cards: - text += "\n\n" + get_text("payment_method_none") - - await callback.message.edit_text( - text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n) - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data == "pm:bind") -async def payment_method_bind( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - yookassa_service: YooKassaService, -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} - resp = await yookassa_service.create_payment( - amount=1.00, - currency="RUB", - description="Bind card", - metadata=metadata, - receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, - save_payment_method=True, - capture=False, - bind_only=True, - ) - if not resp or not resp.get("confirmation_url"): - await callback.answer(_("error_payment_gateway"), show_alert=True) - return - await callback.message.edit_text( - _("payment_methods_title"), - reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n), - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:delete_confirm")) -async def payment_method_delete_confirm( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - parts = callback.data.split(":", 2) - pm_id = parts[2] if len(parts) >= 3 else "" - await callback.message.edit_text( - _("payment_method_delete_confirm"), - reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n), - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:delete")) -async def payment_method_delete( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - parts = callback.data.split(":", 2) - pm_id_raw = parts[2] if len(parts) >= 3 else "" - deleted = False - - try: - from db.dal.user_billing_dal import ( - delete_user_payment_method, - delete_user_payment_method_by_provider_id, - list_user_payment_methods, - ) - - if pm_id_raw: - if pm_id_raw.isdigit(): - deleted = await delete_user_payment_method( - session, callback.from_user.id, int(pm_id_raw) - ) - else: - deleted = await delete_user_payment_method_by_provider_id( - session, callback.from_user.id, pm_id_raw - ) - try: - legacy_deleted = await user_billing_dal.delete_yk_payment_method( - session, callback.from_user.id - ) - deleted = deleted or legacy_deleted - except Exception: - pass - await session.commit() - - methods = await list_user_payment_methods(session, callback.from_user.id) - text = _("payment_methods_title") - cards = [] - for m in methods: - - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic") - return _("payment_method_generic_title", network=network_name) - - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - if not cards: - text += "\n\n" + _("payment_method_none") - msg = _("payment_method_deleted_success") if deleted else _("error_try_again") - await callback.message.edit_text( - f"{msg}\n\n{text}", - reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n), - ) - try: - await callback.answer() - except Exception: - pass - return - except Exception: - await session.rollback() - try: - await callback.answer(_("error_try_again"), show_alert=True) - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:view")) -async def payment_method_view( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) - if not billing or not billing.yookassa_payment_method_id: - from db.dal.user_billing_dal import list_user_payment_methods - - methods = await list_user_payment_methods(session, callback.from_user.id) - if not methods: - await callback.answer(_("payment_method_none"), show_alert=True) - return - parts = callback.data.split(":", 2) - pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id) - sel = next( - ( - m - for m in methods - if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id - ), - methods[0], - ) - - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic") - return _("payment_method_generic_title", network=network_name) - - title = _format_pm_title(sel.card_network, sel.card_last4) - added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else "—" - last_tx = "—" - try: - stmt = ( - select(Payment) - .where( - Payment.user_id == callback.from_user.id, - Payment.status == "succeeded", - Payment.provider == "yookassa", - ) - .order_by(Payment.created_at.desc()) - .limit(1) - ) - result = await session.execute(stmt) - lp = result.scalar_one_or_none() - if lp and lp.created_at: - last_tx = lp.created_at.strftime("%Y-%m-%d") - except Exception: - pass - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 - await callback.message.edit_text( - details, - reply_markup=get_payment_method_details_keyboard( - str(sel.method_id), current_lang, i18n - ), - ) - try: - await callback.answer() - except Exception: - pass - return - - added_at = ( - billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else "—" - ) - last_tx = "—" - try: - stmt = ( - select(Payment) - .where( - Payment.user_id == callback.from_user.id, - Payment.status == "succeeded", - Payment.provider == "yookassa", - ) - .order_by(Payment.created_at.desc()) - .limit(1) - ) - result = await session.execute(stmt) - last_payment = result.scalar_one_or_none() - if last_payment and last_payment.created_at: - last_tx = last_payment.created_at.strftime("%Y-%m-%d") - except Exception: - pass - - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic") - return _("payment_method_generic_title", network=network_name) - - title = _format_pm_title(billing.card_network, billing.card_last4) - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 - await callback.message.edit_text( - details, - reply_markup=get_payment_method_details_keyboard( - billing.yookassa_payment_method_id, current_lang, i18n - ), - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:history")) -async def payment_method_history( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - yookassa_service: YooKassaService, -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not settings.yookassa_autopayments_active: - try: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - await callback.answer(_("error_service_unavailable"), show_alert=True) - except Exception: - pass - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - from db.dal import payment_dal - - payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0) - user_payments = [p for p in payments if p.user_id == callback.from_user.id] - - selected_pm_provider_id: Optional[str] = None - pm_filter_requested: bool = False - try: - split_a, split_b, split_pm_id = callback.data.split(":", 2) - if split_pm_id: - pm_filter_requested = True - if split_pm_id.isdigit(): - from db.dal.user_billing_dal import list_user_payment_methods - - methods = await list_user_payment_methods(session, callback.from_user.id) - sel = next((m for m in methods if str(m.method_id) == split_pm_id), None) - if sel and sel.provider_payment_method_id: - selected_pm_provider_id = sel.provider_payment_method_id - else: - selected_pm_provider_id = split_pm_id - except Exception: - selected_pm_provider_id = None - pm_filter_requested = False - - if pm_filter_requested and not selected_pm_provider_id: - user_payments = [] - - if selected_pm_provider_id: - filtered: List[Payment] = [] - for p in user_payments: - if p.provider != "yookassa": - continue - if p.yookassa_payment_id and yookassa_service: - try: - info = await yookassa_service.get_payment_info(p.yookassa_payment_id) - pm = (info or {}).get("payment_method") or {} - if pm.get("id") == selected_pm_provider_id: - filtered.append(p) - continue - except Exception: - pass - user_payments = filtered - - if not user_payments: - from bot.keyboards.inline.user_keyboards import ( - get_back_to_payment_method_details_keyboard, - get_payment_methods_manage_keyboard, - ) - - back_pm_id = "" - try: - split_a, split_b, back_pm_id = callback.data.split(":", 2) - except Exception: - back_pm_id = "" - back_markup = ( - get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n) - if back_pm_id - else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) - ) - await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) - return - - traffic_mode = getattr(settings, "traffic_sale_mode", False) - - def _format_item(p: Payment) -> str: - if traffic_mode: - units_val = p.subscription_duration_months or 0 - units_display = ( - str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" - ) - title = p.description or _("traffic_purchase_title", traffic_gb=units_display) - else: - title = p.description or _( - "subscription_purchase_title", months=p.subscription_duration_months or 1 - ) - date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A" - return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" - - lines = [_format_item(p) for p in user_payments] - text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) - try: - split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) - except Exception: - split_pm_id_for_back = "" - from bot.keyboards.inline.user_keyboards import ( - get_back_to_payment_method_details_keyboard, - get_payment_methods_manage_keyboard, - ) - - back_markup = ( - get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n) - if split_pm_id_for_back - else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) - ) - await callback.message.edit_text(text, reply_markup=back_markup) - - -@router.callback_query(F.data.startswith("pm:list:")) -async def payment_methods_list( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, 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 - - from db.dal.user_billing_dal import list_user_payment_methods - - cards: List[tuple] = [] - methods = await list_user_payment_methods(session, callback.from_user.id) - for m in methods: - - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return get_text("payment_method_wallet_title", last4=l4) - return get_text("payment_method_wallet_title", last4="****") - if last4: - network_name = network or get_text("payment_network_card") - return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic") - return get_text("payment_method_generic_title", network=network_name) - - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - - try: - _, _, page_str = callback.data.split(":", 2) - page = int(page_str) - except Exception: - page = 0 - - text = get_text("payment_methods_title") - if not cards: - text += "\n\n" + get_text("payment_method_none") - await callback.message.edit_text( - text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n) - ) - try: - await callback.answer() - except Exception: - pass diff --git a/backend/bot/handlers/user/subscription/payments.py b/backend/bot/handlers/user/subscription/payments.py index 1c64e50..6b37690 100644 --- a/backend/bot/handlers/user/subscription/payments.py +++ b/backend/bot/handlers/user/subscription/payments.py @@ -1,23 +1,13 @@ from aiogram import Router -from .payments_crypto import router as crypto_router -from .payments_freekassa import router as freekassa_router -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 bot.payment_providers import iter_unique_provider_routers + 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") router.include_router(subscription_selection_router) -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) +for provider_router in iter_unique_provider_routers(): + router.include_router(provider_router) __all__ = ["router"] diff --git a/backend/bot/handlers/user/subscription/payments_crypto.py b/backend/bot/handlers/user/subscription/payments_crypto.py deleted file mode 100644 index 535f53b..0000000 --- a/backend/bot/handlers/user/subscription/payments_crypto.py +++ /dev/null @@ -1,128 +0,0 @@ -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.crypto_pay_service import CryptoPayService -from config.settings import Settings - -router = Router(name="user_subscription_payments_crypto_router") - - -@router.callback_query(F.data.startswith("pay_crypto:")) -async def pay_crypto_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - cryptopay_service: CryptoPayService, -): - 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 settings.CRYPTOPAY_ENABLED - or not cryptopay_service - or not getattr(cryptopay_service, "configured", False) - ): - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - parts = data_payload.split(":") - months = float(parts[0]) - price_amount = float(parts[1]) - sale_mode = parts[2] if len(parts) > 2 else "subscription" - except (ValueError, IndexError): - 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)) - ) - ) - - invoice_url = await cryptopay_service.create_invoice( - session=session, - user_id=user_id, - months=months, - amount=price_amount, - description=payment_description, - sale_mode=sale_mode, - ) - - if invoice_url: - 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( - invoice_url, - 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: - 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( - invoice_url, - 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 - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass diff --git a/backend/bot/handlers/user/subscription/payments_freekassa.py b/backend/bot/handlers/user/subscription/payments_freekassa.py deleted file mode 100644 index ac68b05..0000000 --- a/backend/bot/handlers/user/subscription/payments_freekassa.py +++ /dev/null @@ -1,244 +0,0 @@ -import logging -from datetime import datetime -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.freekassa_service import FreeKassaService -from config.settings import Settings -from db.dal import payment_dal - -router = Router(name="user_subscription_payments_freekassa_router") - - -@router.callback_query(F.data.startswith("pay_fk:")) -async def pay_fk_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - freekassa_service: FreeKassaService, - 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 freekassa_service or not freekassa_service.configured: - logging.error("FreeKassa 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(f"Invalid pay_fk data in callback: {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 = ( - getattr(freekassa_service, "default_currency", None) - or settings.DEFAULT_CURRENCY_SYMBOL - or "RUB" - ) - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_freekassa", - "description": payment_description, - "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "provider": "freekassa", - "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( - f"FreeKassa: failed to create payment record for user {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 freekassa_service.create_order( - payment_db_id=payment_record.payment_id, - user_id=payment_record.user_id, - months=months, - amount=price_rub, - currency=freekassa_service.default_currency, - payment_method_id=freekassa_service.payment_method_id, - ip_address=freekassa_service.server_ip, - extra_params={ - "us_method": freekassa_service.payment_method_id, - }, - ) - - if success: - location = response_data.get("location") - order_hash = response_data.get("orderHash") - order_id_api = response_data.get("orderId") - provider_identifier = order_hash or order_id_api - - 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( - f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 - exc_info=True, - ) - - if location: - order_identifier_display = str( - order_id_api or provider_identifier or payment_record.payment_id - ) - order_info_text = get_text( - "free_kassa_order_info", - order_id=order_identifier_display, - date=datetime.now().strftime("%Y-%m-%d"), - ) - try: - await callback.message.edit_text( - f"{order_info_text}\n\n" - + 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( - location, - 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( - f"FreeKassa: failed to display payment link ({e_edit}), sending new message." - ) - try: - await callback.message.answer( - f"{order_info_text}\n\n" - + 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( - location, - 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( - "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501 - payment_record.payment_id, - response_data, - ) - else: - logging.error( - "FreeKassa: create_order failed for payment %s with 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( - f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 - 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/handlers/user/subscription/payments_platega.py b/backend/bot/handlers/user/subscription/payments_platega.py deleted file mode 100644 index 3de1392..0000000 --- a/backend/bot/handlers/user/subscription/payments_platega.py +++ /dev/null @@ -1,261 +0,0 @@ -import json -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.platega_service import PlategaService -from config.settings import Settings -from db.dal import payment_dal - -router = Router(name="user_subscription_payments_platega_router") - - -@router.callback_query( - F.data.startswith("pay_platega_sbp:") - | F.data.startswith("pay_platega_crypto:") - | F.data.startswith("pay_platega:") -) -async def pay_platega_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - platega_service: PlategaService, - session: AsyncSession, -): - callback_prefix, _, _ = (callback.data or "").partition(":") - if callback_prefix == "pay_platega_crypto": - platega_method_id = settings.PLATEGA_CRYPTO_METHOD - platega_variant = "crypto" - if not settings.PLATEGA_CRYPTO_ENABLED: - try: - await callback.answer() - except Exception: - pass - return - elif callback_prefix == "pay_platega_sbp": - platega_method_id = settings.platega_sbp_method_resolved - platega_variant = "sbp" - if not settings.PLATEGA_SBP_ENABLED: - try: - await callback.answer() - except Exception: - pass - return - else: - # Legacy callback (pre-split): keep working as SBP - platega_method_id = settings.platega_sbp_method_resolved - platega_variant = "sbp" - 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 platega_service or not platega_service.configured: - logging.error("Platega 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(f"Invalid pay_platega data in callback: {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_platega", - "description": payment_description, - "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "provider": "platega", - "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( - f"Platega: failed to create payment record for user {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 - - payload_meta = json.dumps( - { - "payment_db_id": payment_record.payment_id, - "user_id": user_id, - "months": months, - "sale_mode": sale_mode, - "platega_variant": platega_variant, - } - ) - - success, response_data = await platega_service.create_transaction( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - payload=payload_meta, - payment_method=platega_method_id, - ) - - if success: - transaction_id = response_data.get("transactionId") or response_data.get("id") - redirect_url = ( - response_data.get("redirect") - or response_data.get("url") - or response_data.get("paymentUrl") - ) - provider_status = response_data.get("status", payment_record.status) - - if transaction_id and redirect_url: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(transaction_id), - str(provider_status), - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error( - f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 - exc_info=True, - ) - - 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( - redirect_url, - 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( - f"Platega: failed to display payment link ({e_edit}), sending new message." - ) - 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( - redirect_url, - 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( - "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501 - 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( - f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 - 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/handlers/user/subscription/payments_severpay.py b/backend/bot/handlers/user/subscription/payments_severpay.py deleted file mode 100644 index 0326b99..0000000 --- a/backend/bot/handlers/user/subscription/payments_severpay.py +++ /dev/null @@ -1,221 +0,0 @@ -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.severpay_service import SeverPayService -from config.settings import Settings -from db.dal import payment_dal - -router = Router(name="user_subscription_payments_severpay_router") - - -@router.callback_query(F.data.startswith("pay_severpay:")) -async def pay_severpay_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - severpay_service: SeverPayService, - 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 severpay_service or not severpay_service.configured: - logging.error("SeverPay 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(f"Invalid pay_severpay data in callback: {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_severpay", - "description": payment_description, - "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "provider": "severpay", - "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( - f"SeverPay: failed to create payment record for user {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 severpay_service.create_payment( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - ) - - if success: - payment_link = ( - response_data.get("url") - or response_data.get("payment_url") - or response_data.get("paymentUrl") - ) - provider_identifier = response_data.get("id") or response_data.get("uid") - - 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( - f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 - 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( - f"SeverPay: failed to display payment link ({e_edit}), sending new message." - ) - 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( - "SeverPay: payment created but missing payment link 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( - f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 - 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/handlers/user/subscription/payments_stars.py b/backend/bot/handlers/user/subscription/payments_stars.py deleted file mode 100644 index 68c019b..0000000 --- a/backend/bot/handlers/user/subscription/payments_stars.py +++ /dev/null @@ -1,148 +0,0 @@ -import logging -from typing import Optional - -from aiogram import F, Router, types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from sqlalchemy.ext.asyncio import AsyncSession - -from bot.middlewares.i18n import JsonI18n -from bot.services.stars_service import StarsService -from config.settings import Settings - -router = Router(name="user_subscription_payments_stars_router") - - -@router.callback_query(F.data.startswith("pay_stars:")) -async def pay_stars_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - stars_service: StarsService, -): - 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 settings.STARS_ENABLED: - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - parts = data_payload.split(":") - months = float(parts[0]) - stars_price = int(float(parts[1])) - sale_mode = parts[2] if len(parts) > 2 else "subscription" - except (ValueError, IndexError): - 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)) - ) - ) - - payment_db_id = await stars_service.create_invoice( - session=session, - user_id=user_id, - months=months, - stars_price=stars_price, - description=payment_description, - sale_mode=sale_mode, - ) - - if payment_db_id: - try: - await callback.message.edit_text( - get_text( - "payment_invoice_sent_message_traffic" - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else "payment_invoice_sent_message", - months=int(months), - traffic_gb=human_value, - ), - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=get_text("back_to_payment_methods_button"), - callback_data=f"subscribe_period:{human_value}", - ) - ] - ] - ), - ) - except Exception as e_edit: - logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") - try: - await callback.answer() - except Exception: - pass - return - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.pre_checkout_query() -async def handle_pre_checkout_query(query: types.PreCheckoutQuery): - try: - await query.answer(ok=True) - except Exception: - # Nothing else to do here; Telegram will show an error if not answered - pass - - -@router.message(F.successful_payment) -async def handle_successful_stars_payment( - message: types.Message, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - stars_service: StarsService, -): - payload = ( - message.successful_payment.invoice_payload if message and message.successful_payment else "" - ) - try: - parts = (payload or "").split(":") - payment_db_id = int(parts[0]) - months = float(parts[1]) if len(parts) > 1 else 0 - sale_mode = parts[2] if len(parts) > 2 else "subscription" - except Exception: - return - - stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 - await stars_service.process_successful_payment( - session=session, - message=message, - payment_db_id=payment_db_id, - months=months, - stars_amount=stars_amount, - i18n_data=i18n_data, - sale_mode=sale_mode, - ) diff --git a/backend/bot/handlers/user/subscription/payments_wata.py b/backend/bot/handlers/user/subscription/payments_wata.py deleted file mode 100644 index 21b4ad7..0000000 --- a/backend/bot/handlers/user/subscription/payments_wata.py +++ /dev/null @@ -1,223 +0,0 @@ -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/handlers/user/subscription/payments_yookassa.py b/backend/bot/handlers/user/subscription/payments_yookassa.py deleted file mode 100644 index d03659e..0000000 --- a/backend/bot/handlers/user/subscription/payments_yookassa.py +++ /dev/null @@ -1,842 +0,0 @@ -import logging -from typing import List, Optional, Tuple - -from aiogram import F, Router, types -from sqlalchemy.ext.asyncio import AsyncSession - -from bot.keyboards.inline.user_keyboards import ( - get_back_to_main_menu_markup, - get_payment_url_keyboard, - get_yk_autopay_choice_keyboard, - get_yk_saved_cards_keyboard, -) -from bot.middlewares.i18n import JsonI18n -from bot.services.yookassa_service import YooKassaService -from config.settings import Settings -from db.dal import payment_dal, user_billing_dal - -router = Router(name="user_subscription_payments_yookassa_router") - - -def _format_value(val: float) -> str: - return str(int(val)) if float(val).is_integer() else f"{val:g}" - - -def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: - try: - parts = payload.split(":") - value = float(parts[0]) - price = float(parts[1]) - sale_mode = parts[2] if len(parts) > 2 else "subscription" - return value, price, sale_mode - except (ValueError, IndexError): - return None - - -def _sale_mode_base(sale_mode: str) -> str: - return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0] - - -def _format_saved_payment_method_title( - get_text, network: Optional[str], last4: Optional[str], is_default: bool -) -> str: - def _is_yoomoney_network(name: Optional[str]) -> bool: - s = (name or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - if _is_yoomoney_network(network): - inferred_last4 = last4 or (_extract_last4(network or "") or "****") - title = get_text("payment_method_wallet_title", last4=inferred_last4) - elif last4: - network_name = network or get_text("payment_network_card") - title = get_text("payment_method_card_title", network=network_name, last4=last4) - else: - network_name = network or get_text("payment_network_generic") - title = get_text("payment_method_generic_title", network=network_name) - return f"⭐ {title}" if is_default else title - - -async def _initiate_yk_payment( - callback: types.CallbackQuery, - *, - settings: Settings, - session: AsyncSession, - yookassa_service: YooKassaService, - i18n: Optional[JsonI18n], - current_lang: str, - get_text, - user_id: int, - months: int, - price_rub: float, - currency_code_for_yk: str, - save_payment_method: bool, - back_callback: str, - payment_method_id: Optional[str] = None, - selected_method_internal_id: Optional[int] = None, - sale_mode: str = "subscription", -) -> bool: - """Create payment record and initiate YooKassa payment (new card or saved card).""" - if not callback.message: - return False - - sale_base = _sale_mode_base(sale_mode) - payment_description = ( - get_text("payment_description_traffic", traffic_gb=_format_value(months)) - 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)) - ) - ) - payment_record_data = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code_for_yk, - "status": "pending_yookassa", - "description": payment_description, - "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "sale_mode": sale_base, - "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, - } - - db_payment_record = None - try: - db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) - await session.commit() - logging.info( - f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501 - ) - except Exception as e_db_payment: - await session.rollback() - logging.error( - f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - if not db_payment_record: - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - yookassa_metadata = { - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(db_payment_record.payment_id), - "sale_mode": sale_mode, - } - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - yookassa_metadata["traffic_gb"] = str(months) - if payment_method_id: - yookassa_metadata["used_saved_payment_method_id"] = payment_method_id - - receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - - payment_response_yk = await yookassa_service.create_payment( - amount=price_rub, - currency=currency_code_for_yk, - description=payment_description, - metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk, - save_payment_method=save_payment_method, - payment_method_id=payment_method_id, - ) - - if payment_response_yk and payment_response_yk.get("confirmation_url"): - pm = payment_response_yk.get("payment_method") - try: - if pm and pm.get("id"): - pm_type = pm.get("type") - title = pm.get("title") - card = pm.get("card") or {} - account_number = pm.get("account_number") or pm.get("account") - if isinstance(card, dict) and (pm_type or "").lower() in { - "bank_card", - "bank-card", - "card", - }: - display_network = card.get("card_type") or title or "Card" - display_last4 = card.get("last4") - elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: - display_network = "YooMoney" - display_last4 = ( - account_number[-4:] - if isinstance(account_number, str) and len(account_number) >= 4 - else None - ) - else: - display_network = title or (pm_type.upper() if pm_type else "Payment method") - display_last4 = None - await user_billing_dal.upsert_yk_payment_method( - session, - user_id=user_id, - payment_method_id=pm["id"], - card_last4=display_last4, - card_network=display_network, - ) - try: - await user_billing_dal.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=pm["id"], - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=save_payment_method, - ) - except Exception: - pass - await session.commit() - except Exception: - await session.rollback() - logging.exception("Failed to save YooKassa payment method preliminarily") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=payment_response_yk.get("status", "pending"), - yk_payment_id=payment_response_yk.get("id"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception( - "Failed to set default payment method after initiating payment" - ) - await session.commit() - except Exception as e_db_update_ykid: - await session.rollback() - logging.error( - f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501 - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) - except Exception: - pass - return False - - 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=_format_value(months), - ), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], - current_lang, - i18n, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.") - 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=_format_value(months), - ), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], - current_lang, - i18n, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - return True - - if payment_response_yk and payment_method_id: - status_to_store = payment_response_yk.get("status", "pending") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=status_to_store, - yk_payment_id=payment_response_yk.get("id"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception( - "Failed to set default payment method after saved-card payment start" - ) - await session.commit() - except Exception as e_db_update_saved: - await session.rollback() - logging.error( - f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501 - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - message_text = get_text("yookassa_autopay_charge_initiated") - try: - await callback.message.edit_text( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception as e_edit: - logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") - try: - await callback.message.answer( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception: - pass - return True - - try: - await payment_dal.update_payment_status_by_db_id( - session, db_payment_record.payment_id, "failed_creation" - ) - await session.commit() - except Exception as e_db_fail_create: - await session.rollback() - logging.error( - f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501 - exc_info=True, - ) - logging.error( - f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501 - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - -@router.callback_query(F.data.startswith("pay_yk:")) -async def pay_yk_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - yookassa_service: YooKassaService, - 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 yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service is not configured or unavailable.") - target_msg_edit = callback.message - await target_msg_edit.edit_text(get_text("payment_service_unavailable")) - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parsed = _parse_offer_payload(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub, sale_mode = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool( - settings.yookassa_autopayments_active - and _sale_mode_base(sale_mode) == "subscription" - and not settings.traffic_sale_mode - ) - autopay_require_binding = bool( - getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True) - ) - saved_methods: List = [] - if autopay_enabled: - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if autopay_enabled and saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - sale_mode=sale_mode, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") - try: - await callback.message.answer( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - sale_mode=sale_mode, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{_format_value(months)}", - sale_mode=sale_mode, - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_new:")) -async def pay_yk_new_card_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - yookassa_service: YooKassaService, - 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 yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service unavailable for pay_yk_new.") - 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) - except ValueError: - logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parsed = _parse_offer_payload(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub, sale_mode = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool( - settings.yookassa_autopayments_active - and _sale_mode_base(sale_mode) == "subscription" - and not settings.traffic_sale_mode - ) - autopay_require_binding = bool( - getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True) - ) - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{_format_value(months)}", - sale_mode=sale_mode, - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_saved_list:")) -async def pay_yk_saved_list_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - yookassa_service: YooKassaService, - 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 - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 2: - logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - autopay_enabled = bool( - settings.yookassa_autopayments_active - and _sale_mode_base(sale_mode) == "subscription" - and not settings.traffic_sale_mode - ) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if not saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - sale_mode=sale_mode, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display no-saved-card notice: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - sale_mode=sale_mode, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - cards: List[Tuple[str, str]] = [] - for method in saved_methods: - title = _format_saved_payment_method_title( - get_text, method.card_network, method.card_last4, method.is_default - ) - cards.append((str(method.method_id), title)) - - per_page = 5 - max_page = max(0, (len(cards) - 1) // per_page) - page = max(0, min(page, max_page)) - - try: - await callback.message.edit_text( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - sale_mode=sale_mode, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display saved card list: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - sale_mode=sale_mode, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_use_saved:")) -async def pay_yk_use_saved_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - yookassa_service: YooKassaService, - 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 yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service unavailable for pay_yk_use_saved.") - 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) - except ValueError: - logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 3: - logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - autopay_enabled = bool( - settings.yookassa_autopayments_active - and _sale_mode_base(sale_mode) == "subscription" - and not settings.traffic_sale_mode - ) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - method_identifier = parts[2] - user_id = callback.from_user.id - - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - selected_method = None - for method in saved_methods: - if method_identifier.isdigit(): - if method.method_id == int(method_identifier): - selected_method = method - break - if method.provider_payment_method_id == method_identifier: - selected_method = method - break - - if not selected_method: - logging.warning( - f"Selected payment method not found for user {user_id}: {method_identifier}" - ) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_code_for_yk = "RUB" - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=False, - back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", - payment_method_id=selected_method.provider_payment_method_id, - selected_method_internal_id=selected_method.method_id, - sale_mode=sale_mode, - ) - try: - await callback.answer() - except Exception: - pass diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 9253e88..234d2a9 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -344,7 +344,6 @@ def get_payment_method_keyboard( return str(int(val)) if float(val).is_integer() else f"{val:g}" value_str = _format_value(months) - mode_suffix = f":{sale_mode}" import logging as _kbd_logging _kbd_logging.info( @@ -354,51 +353,24 @@ def get_payment_method_keyboard( settings.PLATEGA_SBP_ENABLED, settings.PLATEGA_CRYPTO_ENABLED, ) + from bot.payment_providers import get_provider_spec, provider_telegram_button_text + for method in settings.payment_methods_order: - if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): - builder.button( - 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"), - callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}", - ) - elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED: - builder.button( - text=_("pay_with_platega_sbp_button"), - callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}", - ) - elif ( - method == "platega_crypto" - and settings.PLATEGA_ENABLED - and settings.PLATEGA_CRYPTO_ENABLED - ): - builder.button( - text=_("pay_with_platega_crypto_button"), - callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}", - ) - elif method == "yookassa" and settings.YOOKASSA_ENABLED: - builder.button( - text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}", - ) - elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: - builder.button( - text=_("pay_with_stars_button"), - callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}", - ) - elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED: - builder.button( - text=_("pay_with_cryptopay_button"), - callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}", - ) + spec = get_provider_spec(method) + if not spec or not spec.button_text_key or not spec.is_enabled(settings): + continue + callback_data = spec.callback_data( + value=value_str, + rub_price=price, + stars_price=stars_price, + sale_mode=sale_mode, + ) + if not callback_data: + continue + builder.button( + text=provider_telegram_button_text(spec, settings, _, language=lang), + callback_data=callback_data, + ) builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") builder.adjust(1) return builder.as_markup() diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index b7086d6..e4e28be 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -187,20 +187,16 @@ async def on_shutdown_configured(dispatcher: Dispatcher): except Exception as e: logging.warning(f"Failed to close session for {key}: {e}") + from bot.payment_providers import iter_service_keys + for service_key in ( "panel_service", - "cryptopay_service", - "freekassa_service", "panel_webhook_service", - "yookassa_service", "lknpd_service", "promo_code_service", - "stars_service", "subscription_service", "referral_service", - "platega_service", - "severpay_service", - "wata_service", + *iter_service_keys(), ): await close_service(service_key) diff --git a/backend/bot/payment_providers/__init__.py b/backend/bot/payment_providers/__init__.py new file mode 100644 index 0000000..a8e66a7 --- /dev/null +++ b/backend/bot/payment_providers/__init__.py @@ -0,0 +1,37 @@ +from .base import ( + PaymentProviderPresentation, + PaymentProviderSpec, + ServiceFactoryContext, + WebAppPaymentContext, +) +from .registry import ( + PAYMENT_PROVIDER_SPECS, + build_provider_services, + get_provider_spec, + iter_provider_specs, + iter_service_keys, + iter_unique_provider_routers, + pending_statuses, + provider_emoji_map, + provider_label_map, + provider_telegram_button_text, + resolve_provider_presentation, +) + +__all__ = [ + "PAYMENT_PROVIDER_SPECS", + "PaymentProviderPresentation", + "PaymentProviderSpec", + "ServiceFactoryContext", + "WebAppPaymentContext", + "build_provider_services", + "get_provider_spec", + "iter_provider_specs", + "iter_service_keys", + "iter_unique_provider_routers", + "pending_statuses", + "provider_telegram_button_text", + "provider_emoji_map", + "provider_label_map", + "resolve_provider_presentation", +] diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py new file mode 100644 index 0000000..c787b8d --- /dev/null +++ b/backend/bot/payment_providers/base.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence + + +@dataclass(frozen=True) +class ServiceFactoryContext: + settings: Any + bot: Any + async_session_factory: Any + i18n: Any + bot_username_for_default_return: str + subscription_service: Any + referral_service: Any + + +@dataclass(frozen=True) +class WebAppPaymentContext: + request: Any + session: Any + user_id: int + method: str + months: Any + price: float + stars_price: Optional[int] + description: str + sale_mode: str + traffic_gb: Optional[float] = None + + +EnabledPredicate = Callable[[Any], bool] +ServiceFactory = Callable[[ServiceFactoryContext], Any] +WebhookPathGetter = Callable[[Any], str] +WebhookRoute = Callable[[Any], Awaitable[Any]] +WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] + + +@dataclass(frozen=True) +class PaymentProviderSpec: + id: str + provider_key: str + label: str + pending_status: str + enabled: EnabledPredicate + service_key: Optional[str] = None + button_text_key: Optional[str] = None + callback_prefix: Optional[str] = None + webapp_label: Optional[str] = None + webapp_labels: Optional[Mapping[str, str]] = None + telegram_labels: Optional[Mapping[str, str]] = None + aliases: Sequence[str] = () + router: Any = None + create_service: Optional[ServiceFactory] = None + webhook_path: Optional[WebhookPathGetter] = None + webhook_route: Optional[WebhookRoute] = None + webhook_requires_base_url: bool = False + create_webapp_payment: Optional[WebAppPaymentFactory] = None + requires_configured_service: bool = True + price_source: str = "rub" + emoji: str = "💳" + webapp_icon: Optional[str] = None + telegram_emoji: Optional[str] = None + + @property + def settings_key(self) -> str: + return self.id.upper() + + @property + def default_telegram_emoji(self) -> str: + return self.telegram_emoji or self.emoji + + @property + def method_ids(self) -> tuple[str, ...]: + return (self.id, *tuple(self.aliases)) + + def is_enabled(self, settings: Any) -> bool: + return bool(self.enabled(settings)) + + def is_service_configured(self, app: Any) -> bool: + if not self.requires_configured_service: + return True + if not self.service_key: + return True + service = app.get(self.service_key) if hasattr(app, "get") else None + return bool(service and getattr(service, "configured", False)) + + def is_visible(self, settings: Any, app: Any) -> bool: + return self.is_enabled(settings) and self.is_service_configured(app) + + def load_router(self) -> Any: + return self.router + + def load_webhook_route(self) -> Optional[WebhookRoute]: + return self.webhook_route + + def callback_data( + self, + *, + value: str, + rub_price: float, + stars_price: Optional[int], + sale_mode: str, + ) -> Optional[str]: + if not self.callback_prefix: + return None + if self.price_source == "stars": + if stars_price is None: + return None + price: Any = stars_price + else: + price = rub_price + return f"{self.callback_prefix}:{value}:{price}:{sale_mode}" + + +@dataclass(frozen=True) +class PaymentProviderPresentation: + webapp_label: str + webapp_icon: Optional[str] + telegram_label: str + telegram_emoji: str + telegram_customized: bool diff --git a/backend/bot/services/crypto_pay_service.py b/backend/bot/payment_providers/cryptopay.py similarity index 51% rename from backend/bot/services/crypto_pay_service.py rename to backend/bot/payment_providers/cryptopay.py index 14e8bec..779951a 100644 --- a/backend/bot/services/crypto_pay_service.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -6,22 +6,36 @@ from typing import Optional from aiocryptopay import AioCryptoPay, Networks from aiocryptopay.models.update import Update -from aiogram import Bot +from aiogram import Bot, F, Router, types from aiohttp import web from sqlalchemy.ext.asyncio import AsyncSession 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.text_sanitizer import sanitize_display_name, username_for_display from config.settings import Settings -from db.dal import payment_dal, user_dal +from db.dal import payment_dal + +from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext +from .shared import ( + PaymentSuccessRequest, + describe_payment, + finalize_successful_payment, + make_translator, + notify_callback_parse_error, + notify_service_unavailable, + parse_payment_callback, + payment_failed, + payment_link_response, + payment_unavailable, + render_payment_link, + sale_mode_base, + sale_mode_tariff_key, +) logger = logging.getLogger(__name__) +_LOG = "cryptopay" class CryptoPayService: @@ -54,13 +68,12 @@ class CryptoPayService: self.configured = False async def close(self): - """Close underlying AioCryptoPay session if initialized.""" if self.client: try: await self.client.close() logging.info("CryptoPay client session closed.") except Exception as e: - logging.warning(f"Failed to close CryptoPay client: {e}") + logging.warning("Failed to close CryptoPay client: %s", e) async def create_invoice( self, @@ -76,9 +89,9 @@ class CryptoPayService: logging.error("CryptoPayService not configured") return None - # Create pending payment in DB and commit to persist + sale_base = sale_mode_base(sale_mode) + is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} try: - sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_record = await payment_dal.create_payment_record( session, { @@ -87,34 +100,28 @@ class CryptoPayService: "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", "description": description, - "subscription_duration_months": int(months) - if sale_base == "subscription" - else None, + "subscription_duration_months": ( + int(months) if sale_base == "subscription" else None + ), "provider": "cryptopay", "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, + "tariff_key": sale_mode_tariff_key(sale_mode), + "purchased_gb": float(months) if is_traffic else None, }, ) await session.commit() - except Exception as e_db_create: + except Exception: await session.rollback() - logging.error( - f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}", - exc_info=True, - ) + logging.exception("Failed to create cryptopay payment record for user %s.", user_id) return None + payload = json.dumps( { "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, - "traffic_gb": str(months) - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, + "traffic_gb": str(months) if is_traffic else None, } ) try: @@ -169,7 +176,6 @@ class CryptoPayService: sale_mode = meta.get("sale_mode") or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) - sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months except Exception: logging.exception("Failed to parse CryptoPay payload.") @@ -190,135 +196,44 @@ class CryptoPayService: str(invoice.invoice_id), "succeeded", ) - activation = await subscription_service.activate_subscription( - session, - user_id, - int(months) if sale_base == "subscription" else int(float(traffic_gb)), - float(invoice.amount), - payment_db_id, - provider="cryptopay", - sale_mode=sale_mode, - traffic_gb=traffic_gb - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - ) - referral_bonus = None - if sale_base == "subscription": - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - int(months) or 1, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) await session.commit() except Exception: await session.rollback() - logging.exception("Failed to process CryptoPay invoice.") + logging.exception( + "Failed to mark CryptoPay invoice %s as succeeded.", + payment_db_id, + ) return - db_user = await user_dal.get_user_by_id(session, user_id) - # Use DB language for user-facing messages - lang = ( - db_user.language_code - if db_user and db_user.language_code - else settings.DEFAULT_LANGUAGE - ) - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) - - raw_config_link = activation.get("subscription_url") if activation else None - display_link, button_link = await prepare_config_links(settings, raw_config_link) - config_link_text = display_link or _("config_link_not_available") - final_end = activation.get("end_date") - applied_days = 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) - - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - text = _( - "payment_successful_traffic_full", - traffic_gb=str(int(traffic_gb)) - if float(traffic_gb).is_integer() - else f"{traffic_gb:g}", - 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=int(months), - base_end_date=activation["end_date"].strftime("%Y-%m-%d"), - bonus_days=applied_days, - final_end_date=final_end.strftime("%Y-%m-%d"), - inviter_name=inviter_name_display, - config_link=config_link_text, - ) - else: - text = _( - "payment_successful_full", - months=int(months), - end_date=final_end.strftime("%Y-%m-%d") if final_end else "—", - config_link=config_link_text, + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error( + "CryptoPay webhook: payment %s vanished after status update.", + payment_db_id, ) + return - markup = get_connect_and_main_keyboard( - lang, - i18n, - settings, - display_link, - connect_button_url=button_link, - preserve_message=True, - ) - try: - await bot.send_message( - user_id, - text, - reply_markup=markup, - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception: - logging.exception("Failed to send CryptoPay success message.") - - # Send notification about payment - try: - payment_row = await payment_dal.get_payment_by_db_id(session, payment_db_id) - except Exception: - payment_row = None - try: - notification_service = NotificationService(bot, settings, i18n) - user = await user_dal.get_user_by_id(session, user_id) - await notification_service.notify_payment_received( + currency = invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL + await finalize_successful_payment( + PaymentSuccessRequest( + bot=bot, + settings=settings, + i18n=i18n, + session=session, + subscription_service=subscription_service, + referral_service=referral_service, + payment=payment, user_id=user_id, amount=float(invoice.amount), - currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, - months=int(months) if sale_base == "subscription" else 0, - traffic_gb=traffic_gb - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - payment_provider="crypto_pay", - username=user.username if user else None, - traffic_is_premium=sale_base == "premium_topup", - tariff_key=getattr(payment_row, "tariff_key", None) if payment_row else None, + currency=str(currency), + sale_mode=sale_mode, + months=int(months) if months else int(traffic_gb), + traffic_amount=float(traffic_gb), + provider_subscription="cryptopay", + provider_notification="crypto_pay", + log_prefix="CryptoPay webhook", ) - except Exception: - logging.exception("Failed to send crypto_pay payment notification.") + ) def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool: if not self.token: @@ -347,3 +262,116 @@ class CryptoPayService: async def cryptopay_webhook_route(request: web.Request) -> web.Response: service: CryptoPayService = request.app["cryptopay_service"] return await service.webhook_route(request) + + +router = Router(name="user_subscription_payments_crypto_router") + + +@router.callback_query(F.data.startswith("pay_crypto:")) +async def pay_crypto_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + cryptopay_service: CryptoPayService, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if ( + not settings.CRYPTOPAY_ENABLED + or not cryptopay_service + or not getattr(cryptopay_service, "configured", False) + ): + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + await notify_callback_parse_error(callback, translator) + return + + payment_description = describe_payment(translator, parts) + invoice_url = await cryptopay_service.create_invoice( + session=session, + user_id=callback.from_user.id, + months=parts.months, + amount=parts.price, + description=payment_description, + sale_mode=parts.sale_mode, + ) + + if invoice_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=invoice_url, + log_prefix=_LOG, + ) + return + + from .shared import safe_callback_answer + + await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True) + + +def create_service(ctx: ServiceFactoryContext) -> CryptoPayService: + return CryptoPayService( + ctx.settings.CRYPTOPAY_TOKEN, + ctx.settings.CRYPTOPAY_NETWORK, + ctx.bot, + ctx.settings, + ctx.i18n, + ctx.async_session_factory, + ctx.subscription_service, + ctx.referral_service, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + service: CryptoPayService = ctx.request.app["cryptopay_service"] + if not service or not service.configured: + return payment_unavailable() + url = await service.create_invoice( + session=ctx.session, + user_id=ctx.user_id, + months=ctx.months, + amount=ctx.price, + description=ctx.description, + sale_mode=ctx.sale_mode, + url_kind="web", + ) + if not url: + return payment_failed() + return payment_link_response(payment_url=url, payment_id=None) + + +SPEC = PaymentProviderSpec( + id="cryptopay", + provider_key="cryptopay", + label="CryptoPay", + webapp_label="CryptoPay", + webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"}, + webapp_icon="Bitcoin", + telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"}, + pending_status="pending_cryptopay", + enabled=lambda settings: settings.CRYPTOPAY_ENABLED, + service_key="cryptopay_service", + button_text_key="pay_with_cryptopay_button", + callback_prefix="pay_crypto", + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.cryptopay_webhook_path, + webhook_route=cryptopay_webhook_route, + create_webapp_payment=create_webapp_payment, + emoji="₿", + telegram_emoji="₿", +) diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py new file mode 100644 index 0000000..6ebfc09 --- /dev/null +++ b/backend/bot/payment_providers/freekassa.py @@ -0,0 +1,501 @@ +import asyncio +import hashlib +import hmac +import json +import logging +import time +from datetime import datetime +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl + +from aiogram import Bot, F, Router, types +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from bot.utils.request_security import ip_in_allowlist, request_client_ip +from config.settings import Settings +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ServiceFactoryContext, + WebAppPaymentContext, +) +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + decimal_amounts_equal, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + make_translator, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + parse_payment_callback, + payment_failed, + payment_unavailable, + post_json_request, + render_link_or_fail, +) + +_LOG = "freekassa" + + +class FreeKassaService(HttpClientMixin): + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + ): + 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.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID + self.api_key: Optional[str] = settings.FREEKASSA_API_KEY + self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET + self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP + self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID + + self.api_base_url: str = "https://api.fk.life/v1" + self._init_http_client(total_timeout=15) + self._nonce_lock = asyncio.Lock() + self._last_nonce = int(time.time() * 1000) + + self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key) + if not self.configured: + logging.warning( + "FreeKassaService initialized but not fully configured. Payments disabled." + ) + if settings.FREEKASSA_ENABLED and not self.server_ip: + logging.warning( + "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 + ) + + async def create_order( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + email: Optional[str] = None, + ip_address: Optional[str] = None, + payment_method_id: Optional[int] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("FreeKassaService is not configured. Cannot create order.") + return False, {"message": "service_not_configured"} + + ip_address = ip_address or self.server_ip + if not ip_address: + logging.error("FreeKassaService: payment IP is required but not configured.") + return False, {"message": "missing_ip"} + + email = email or f"{user_id}@telegram.org" + currency_code = (currency or self.default_currency or "RUB").upper() + + payload: Dict[str, Any] = { + "shopId": int(self.shop_id), + "nonce": await self._generate_nonce(), + "paymentId": str(payment_db_id), + "i": int(payment_method_id), + "amount": f"{format_decimal_amount(amount):.2f}", + "currency": currency_code, + "email": email, + "ip": ip_address, + "us_user_id": str(user_id), + "us_months": str(months), + "us_payment_db_id": str(payment_db_id), + } + + if extra_params: + for key, value in extra_params.items(): + if value is None: + continue + payload[key] = value + + payload["signature"] = self._sign_payload(payload) + + session = await self._get_session() + return await post_json_request( + session, + f"{self.api_base_url}/orders/create", + body=payload, + log_prefix="FreeKassa create_order", + # FreeKassa returns ``{"type": "success", ...}`` on success. + is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", + ) + + async def _generate_nonce(self) -> int: + async with self._nonce_lock: + candidate = int(time.time() * 1000) + if candidate <= self._last_nonce: + candidate = self._last_nonce + 1 + self._last_nonce = candidate + return candidate + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + if not self.api_key: + raise RuntimeError("FreeKassa API key is not configured.") + items = [ + (key, value) + for key, value in payload.items() + if key != "signature" and value is not None + ] + items.sort(key=lambda pair: pair[0]) + message = "|".join(str(value) for _, value in items) + return hmac.new( + self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + def _validate_signature(self, raw_body: bytes, provided_signature: str) -> bool: + if not provided_signature or not self.second_secret: + return False + expected_signature = hmac.new( + self.second_secret.encode("utf-8"), + raw_body, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected_signature, provided_signature) + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="freekassa_disabled") + + try: + client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) + if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips): + return web.Response(status=403) + + raw_body = await request.read() + except Exception: + logging.exception("FreeKassa webhook: failed to read request body.") + return web.Response(status=400, text="bad_request") + + payload_dict: Dict[str, Any] = {} + if raw_body: + try: + if request.content_type.startswith("application/json"): + decoded_json = json.loads(raw_body.decode("utf-8")) + if isinstance(decoded_json, dict): + payload_dict = {str(k): v for k, v in decoded_json.items()} + else: + payload_dict = { + str(key): value + for key, value in parse_qsl( + raw_body.decode("utf-8"), keep_blank_values=True + ) + } + except Exception: + payload_dict = {} + + def _get(key: str, default: Optional[str] = None) -> Optional[str]: + return payload_dict.get(key) or payload_dict.get(key.lower()) or default + + merchant_id = _get("MERCHANT_ID") + if merchant_id != self.shop_id: + return web.Response(status=403) + + signature = _get("SIGN") or _get("signature") + if not signature: + return web.Response(status=400, text="missing_signature") + + order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") + amount_str = _get("AMOUNT") or _get("OA") or _get("amount") + provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id") + + if not order_id_str or not amount_str: + return web.Response(status=400, text="missing_data") + + if not self._validate_signature(raw_body, signature): + return web.Response(status=403, text="invalid_signature") + + try: + payment_db_id = int(order_id_str) + except (TypeError, ValueError): + logging.error("FreeKassa webhook: invalid order_id value %r", order_id_str) + return web.Response(status=400, text="invalid_order_id") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error("FreeKassa webhook: payment %s not found", payment_db_id) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded": + logging.info("FreeKassa webhook: payment %s already succeeded", payment_db_id) + return web.Response(text="YES") + + try: + if not decimal_amounts_equal(amount_str, payment.amount): + logging.warning( + "FreeKassa webhook: amount mismatch for payment %s (expected %s, got %s)", + payment_db_id, + format_decimal_amount(payment.amount), + format_decimal_amount(amount_str), + ) + except Exception as exc: + logging.warning( + "FreeKassa webhook: failed to compare amount for payment %s: %s", + payment_db_id, + exc, + ) + + resolved_provider_id = str(provider_payment_id or f"freekassa:{order_id_str}") + try: + await payment_dal.update_provider_payment_and_status( + session=session, + payment_db_id=payment.payment_id, + provider_payment_id=resolved_provider_id, + new_status="succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "FreeKassa webhook: failed to mark payment %s as succeeded.", payment_db_id + ) + return web.Response(status=500, text="processing_error") + + months = 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" + ) + + success_prefix: Optional[str] = None + if provider_payment_id: + # FreeKassa-specific: prepend "Order N from YYYY-MM-DD" to the success text. + # ``i18n`` is resolved inside ``finalize_successful_payment`` per user language, + # so the prefix is built in the admin/default language too — kept here for parity + # with the original behavior, which used the same key. + admin_lang = self.settings.DEFAULT_LANGUAGE + translator = make_translator(self.i18n, admin_lang) + success_prefix = translator( + "free_kassa_order_full", + order_id=provider_payment_id, + date=datetime.now().strftime("%Y-%m-%d"), + ) + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=self.default_currency, + sale_mode=sale_mode, + months=months, + traffic_amount=float(months), + provider_subscription="freekassa", + provider_notification="freekassa", + db_user=payment.user, + log_prefix="FreeKassa webhook", + text_prefix=success_prefix, + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + + return web.Response(text="YES") + + +async def freekassa_webhook_route(request: web.Request) -> web.Response: + service: FreeKassaService = request.app["freekassa_service"] + return await service.webhook_route(request) + + +router = Router(name="user_subscription_payments_freekassa_router") + + +@router.callback_query(F.data.startswith("pay_fk:")) +async def pay_fk_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + freekassa_service: FreeKassaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not freekassa_service or not freekassa_service.configured: + logging.error("FreeKassa service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_fk data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + + currency_code = ( + getattr(freekassa_service, "default_currency", None) + or settings.DEFAULT_CURRENCY_SYMBOL + or "RUB" + ) + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_freekassa", + description=payment_description, + months=parts.months, + provider="freekassa", + sale_mode=parts.sale_mode, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "FreeKassa: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await freekassa_service.create_order( + payment_db_id=payment_record.payment_id, + user_id=payment_record.user_id, + months=parts.months, + amount=parts.price, + currency=freekassa_service.default_currency, + payment_method_id=freekassa_service.payment_method_id, + ip_address=freekassa_service.server_ip, + extra_params={ + "us_method": freekassa_service.payment_method_id, + }, + ) + + location = first_value(response_data, "location") + provider_identifier = first_value(response_data, "orderHash", "orderId") + lead_text: Optional[str] = None + if success and location: + order_id_display = first_value(response_data, "orderId") or provider_identifier or str( + payment_record.payment_id + ) + lead_text = translator( + "free_kassa_order_info", + order_id=order_id_display, + date=datetime.now().strftime("%Y-%m-%d"), + ) + + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=location, + provider_payment_id=provider_identifier, + lead_text=lead_text, + log_prefix=_LOG, + ) + + +def create_service(ctx: ServiceFactoryContext) -> FreeKassaService: + return FreeKassaService( + bot=ctx.bot, + settings=ctx.settings, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + service: FreeKassaService = ctx.request.app["freekassa_service"] + if not service or not service.configured or not service.payment_method_id: + return payment_unavailable() + + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=service.default_currency, + status="pending_freekassa", + provider="freekassa", + ) + success, response_data = await service.create_order( + payment_db_id=payment.payment_id, + user_id=ctx.user_id, + months=ctx.months, + amount=ctx.price, + currency=service.default_currency, + payment_method_id=service.payment_method_id, + ip_address=service.server_ip, + extra_params={"us_method": service.payment_method_id}, + ) + except Exception: + await ctx.session.rollback() + logging.exception("FreeKassa WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(response_data, "location") if success else None, + provider_payment_id=first_value(response_data, "orderHash", "orderId"), + log_prefix="FreeKassa", + ) + + +SPEC = PaymentProviderSpec( + id="freekassa", + provider_key="freekassa", + label="FreeKassa", + webapp_label="FreeKassa / СБП", + webapp_labels={"ru": "FreeKassa / СБП", "en": "FreeKassa / SBP"}, + webapp_icon="Smartphone", + telegram_labels={"ru": "СБП", "en": "SBP"}, + telegram_emoji="📱", + pending_status="pending_freekassa", + enabled=lambda settings: settings.FREEKASSA_ENABLED, + service_key="freekassa_service", + button_text_key="pay_with_sbp_button", + callback_prefix="pay_fk", + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.freekassa_webhook_path, + webhook_route=freekassa_webhook_route, + create_webapp_payment=create_webapp_payment, +) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py new file mode 100644 index 0000000..b3361f0 --- /dev/null +++ b/backend/bot/payment_providers/platega.py @@ -0,0 +1,529 @@ +import hmac +import json +import logging +from typing import Any, Dict, Optional, Tuple + +from aiogram import Bot, F, Router, types +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ServiceFactoryContext, + WebAppPaymentContext, +) +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + decimal_amounts_equal, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + format_number_for_payload, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + notify_user_payment_failed, + parse_payment_callback, + payment_failed, + payment_record_amounts, + payment_unavailable, + post_json_request, + render_link_or_fail, + safe_callback_answer, +) + +_LOG = "platega" + + +class PlategaService(HttpClientMixin): + 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.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/") + self.merchant_id = settings.PLATEGA_MERCHANT_ID + self.secret = settings.PLATEGA_SECRET + self.payment_method = settings.PLATEGA_PAYMENT_METHOD + self.sbp_method = settings.platega_sbp_method_resolved + self.crypto_method = settings.PLATEGA_CRYPTO_METHOD + self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}" + self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url + + self._init_http_client(total_timeout=20) + self._auth_headers = { + "X-MerchantId": self.merchant_id or "", + "X-Secret": self.secret or "", + "Content-Type": "application/json", + } + self.configured: bool = bool(settings.PLATEGA_ENABLED and self.merchant_id and self.secret) + if not self.configured: + logging.warning( + "PlategaService initialized but not fully configured. Payments disabled." + ) + else: + logging.info( + "PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501 + "ON" if settings.PLATEGA_SBP_ENABLED else "OFF", + self.sbp_method, + "ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF", + self.crypto_method, + ) + + async def create_transaction( + self, + *, + amount: float, + currency: Optional[str], + description: str, + payload: Optional[str] = None, + payment_method: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PlategaService is not configured. Cannot create transaction.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/transaction/process" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + method_id = int(payment_method if payment_method is not None else self.payment_method) + + body: Dict[str, Any] = { + "paymentMethod": method_id, + "paymentDetails": {"amount": float(amount), "currency": currency_code}, + "description": description, + "return": self.return_url, + "failedUrl": self.failed_url, + "payload": payload, + } + + # Remove optional keys with falsy values to avoid validation errors + clean_body = {k: v for k, v in body.items() if v not in (None, "")} + safe_headers = { + "X-MerchantId": self._auth_headers.get("X-MerchantId"), + "X-Secret": "***" if self._auth_headers.get("X-Secret") else "", + "Content-Type": self._auth_headers.get("Content-Type"), + } + logging.info( + "Platega create_transaction request: url=%s headers=%s body=%s", + url, + safe_headers, + clean_body, + ) + + return await post_json_request( + session, + url, + body=clean_body, + headers=self._auth_headers, + log_prefix="Platega create_transaction", + ) + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="platega_disabled") + + try: + data = await request.json() + except Exception: + logging.exception("Platega webhook: failed to parse JSON.") + return web.Response(status=400, text="bad_request") + + header_merchant = request.headers.get("X-MerchantId") + header_secret = request.headers.get("X-Secret") + if not ( + hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or "")) + and hmac.compare_digest(str(header_secret or ""), str(self.secret or "")) + ): + logging.error("Platega webhook: invalid auth headers") + return web.Response(status=403, text="forbidden") + + transaction_id = str(data.get("id") or data.get("transactionId") or "").strip() + status = str(data.get("status") or "").upper() + amount_raw = data.get("amount") + currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + if not transaction_id or not status: + logging.error("Platega webhook: missing transaction id or status in payload: %s", data) + return web.Response(status=400, text="missing_fields") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id) + if not payment: + logging.error( + "Platega webhook: payment not found for transaction %s", transaction_id + ) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and status == "CONFIRMED": + return web.Response(text="ok") + + payment_months = 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" + ) + + if status == "CONFIRMED": + if amount_raw is not None: + try: + if not decimal_amounts_equal(amount_raw, payment.amount): + logging.warning( + "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501 + payment.payment_id, + format_decimal_amount(payment.amount), + format_decimal_amount(amount_raw), + ) + except Exception as exc: + logging.warning( + "Platega 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", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Platega webhook: failed to mark payment %s as succeeded.", transaction_id + ) + return web.Response(status=500, text="processing_error") + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=str(currency), + sale_mode=sale_mode, + months=payment_months, + traffic_amount=float(payment_months), + provider_subscription="platega", + provider_notification="platega", + log_prefix="Platega webhook", + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") + + if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + "canceled", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Platega webhook: failed to cancel payment %s.", transaction_id + ) + return web.Response(status=500, text="processing_error") + await notify_user_payment_failed( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + payment=payment, + ) + return web.Response(text="ok_canceled") + + logging.warning( + "Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id + ) + return web.Response(status=202, text="status_ignored") + + +async def platega_webhook_route(request: web.Request) -> web.Response: + service: PlategaService = request.app["platega_service"] + return await service.webhook_route(request) + + +router = Router(name="user_subscription_payments_platega_router") + + +def _resolve_platega_variant(callback_prefix: str, settings: Settings) -> Optional[Tuple[str, int]]: + """Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled.""" + if callback_prefix == "pay_platega_crypto": + if not settings.PLATEGA_CRYPTO_ENABLED: + return None + return "crypto", settings.PLATEGA_CRYPTO_METHOD + if callback_prefix == "pay_platega_sbp": + if not settings.PLATEGA_SBP_ENABLED: + return None + return "sbp", settings.platega_sbp_method_resolved + # Legacy "pay_platega:" callback — keep working as SBP. + return "sbp", settings.platega_sbp_method_resolved + + +@router.callback_query( + F.data.startswith("pay_platega_sbp:") + | F.data.startswith("pay_platega_crypto:") + | F.data.startswith("pay_platega:") +) +async def pay_platega_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + platega_service: PlategaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + from .shared import make_translator + + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + callback_prefix, _, _ = (callback.data or "").partition(":") + variant = _resolve_platega_variant(callback_prefix, settings) + if variant is None: + await safe_callback_answer(callback) + return + platega_variant, platega_method_id = variant + + if not platega_service or not platega_service.configured: + logging.error("Platega service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_platega data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + + currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_platega", + description=payment_description, + months=parts.months, + provider="platega", + sale_mode=parts.sale_mode, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Platega: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + payload_meta = json.dumps( + { + "payment_db_id": payment_record.payment_id, + "user_id": callback.from_user.id, + "months": parts.months, + "sale_mode": parts.sale_mode, + "platega_variant": platega_variant, + } + ) + + success, response_data = await platega_service.create_transaction( + amount=parts.price, + currency=currency_code, + description=payment_description, + payload=payload_meta, + payment_method=platega_method_id, + ) + transaction_id = first_value(response_data, "transactionId", "id") + redirect_url = first_value(response_data, "redirect", "url", "paymentUrl") + provider_status = str((response_data or {}).get("status") or payment_record.status) + # Platega requires *both* a transaction id and a redirect url to count as a + # usable payment — neither field is sufficient on its own. Skipping the + # persistence step when the redirect is missing matches the pre-refactor + # behavior (we never stored a transaction id without a link). + persistable_id = transaction_id if redirect_url else None + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=redirect_url, + provider_payment_id=persistable_id, + new_status=provider_status if persistable_id else None, + log_prefix=_LOG, + ) + + +def create_service(ctx: ServiceFactoryContext) -> PlategaService: + return PlategaService( + bot=ctx.bot, + settings=ctx.settings, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + default_return_url=ctx.bot_username_for_default_return, + ) + + +async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web.Response: + settings = ctx.request.app["settings"] + service: PlategaService = ctx.request.app["platega_service"] + if not service or not service.configured: + return payment_unavailable() + if variant == "platega_crypto": + if not settings.PLATEGA_CRYPTO_ENABLED: + return payment_unavailable() + platega_method_id = settings.PLATEGA_CRYPTO_METHOD + else: + if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: + return payment_unavailable() + platega_method_id = settings.platega_sbp_method_resolved + + try: + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + ) + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + status="pending_platega", + provider="platega", + ) + payload = json.dumps( + { + "payment_db_id": payment.payment_id, + "user_id": ctx.user_id, + "months": amounts.months if not amounts.traffic_sale else 0, + "sale_mode": ctx.sale_mode, + "traffic_gb": format_number_for_payload(ctx.traffic_gb or ctx.months) + if amounts.traffic_sale + else None, + "hwid_devices": amounts.purchased_hwid_devices, + "source": "webapp", + "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", + } + ) + success, response_data = await service.create_transaction( + amount=ctx.price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + description=ctx.description, + payload=payload, + payment_method=platega_method_id, + ) + except Exception: + await ctx.session.rollback() + logging.exception("Platega WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=( + first_value(response_data, "redirect", "url", "paymentUrl") if success else None + ), + provider_payment_id=first_value(response_data, "transactionId", "id"), + new_status=str((response_data or {}).get("status") or payment.status), + log_prefix="Platega", + ) + + +async def create_sbp_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + return await _create_webapp_payment(ctx, "platega_sbp") + + +async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + return await _create_webapp_payment(ctx, "platega_crypto") + + +SBP_SPEC = PaymentProviderSpec( + id="platega_sbp", + provider_key="platega", + label="Platega", + webapp_label="Platega · СБП", + webapp_labels={"ru": "Оплата картой (СБП)", "en": "Pay with card (SBP)"}, + webapp_icon="CreditCard", + telegram_labels={"ru": "Оплата через СБП", "en": "Pay via SBP"}, + telegram_emoji="🏦", + pending_status="pending_platega", + enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED, + service_key="platega_service", + button_text_key="pay_with_platega_sbp_button", + callback_prefix="pay_platega_sbp", + aliases=("platega",), + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.platega_webhook_path, + webhook_route=platega_webhook_route, + create_webapp_payment=create_sbp_webapp_payment, +) + +CRYPTO_SPEC = PaymentProviderSpec( + id="platega_crypto", + provider_key="platega", + label="Platega", + webapp_label="Platega · Crypto", + webapp_labels={"ru": "Крипта", "en": "Crypto"}, + webapp_icon="Bitcoin", + telegram_labels={"ru": "Оплата криптой", "en": "Pay with crypto"}, + telegram_emoji="🪙", + pending_status="pending_platega", + enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED, + service_key="platega_service", + button_text_key="pay_with_platega_crypto_button", + callback_prefix="pay_platega_crypto", + create_webapp_payment=create_crypto_webapp_payment, +) + +SPECS = (SBP_SPEC, CRYPTO_SPEC) diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py new file mode 100644 index 0000000..554d0ec --- /dev/null +++ b/backend/bot/payment_providers/registry.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional + +from . import cryptopay, freekassa, platega, severpay, stars, wata, yookassa +from .base import PaymentProviderPresentation, PaymentProviderSpec, ServiceFactoryContext + +PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( + freekassa.SPEC, + platega.SBP_SPEC, + platega.CRYPTO_SPEC, + severpay.SPEC, + wata.SPEC, + yookassa.SPEC, + stars.SPEC, + cryptopay.SPEC, +) + + +def iter_provider_specs() -> Iterable[PaymentProviderSpec]: + return PAYMENT_PROVIDER_SPECS + + +def get_provider_spec(method: str) -> Optional[PaymentProviderSpec]: + normalized = str(method or "").strip().lower() + for spec in PAYMENT_PROVIDER_SPECS: + if normalized in spec.method_ids: + return spec + return None + + +def _setting_value(settings: Any, key: str) -> Optional[str]: + if settings is None: + return None + value = getattr(settings, key, None) + if value is None: + return None + value = str(value).strip() + return value or None + + +def _presentation_setting(spec: PaymentProviderSpec, suffix: str) -> str: + return f"PAYMENT_{spec.settings_key}_{suffix}" + + +def _normalize_language(language: Optional[str], settings: Any = None) -> str: + value = language or getattr(settings, "DEFAULT_LANGUAGE", None) or "ru" + normalized = str(value).strip().lower().split("-", 1)[0].split("_", 1)[0] + return normalized or "ru" + + +def _localized_setting_value( + settings: Any, + spec: PaymentProviderSpec, + suffix: str, + language: str, +) -> Optional[str]: + return _setting_value( + settings, + _presentation_setting(spec, f"{suffix}_{language.upper()}"), + ) + + +def _localized_default( + values: Optional[Mapping[str, str]], + language: str, + fallback: Optional[str], +) -> Optional[str]: + if not values: + return fallback + return ( + values.get(language) + or values.get("en") + or values.get("ru") + or next(iter(values.values()), None) + or fallback + ) + + +def resolve_provider_presentation( + spec: PaymentProviderSpec, + settings: Any = None, + *, + language: Optional[str] = None, + translate: Optional[Callable[[str], str]] = None, +) -> PaymentProviderPresentation: + lang = _normalize_language(language, settings) + webapp_label = ( + _localized_setting_value(settings, spec, "WEBAPP_LABEL", lang) + or _localized_default(spec.webapp_labels, lang, spec.webapp_label) + or spec.label + ) + webapp_icon = ( + _setting_value(settings, _presentation_setting(spec, "WEBAPP_ICON")) + or spec.webapp_icon + ) + telegram_label_override = _localized_setting_value( + settings, + spec, + "TELEGRAM_LABEL", + lang, + ) + telegram_emoji_override = _setting_value( + settings, _presentation_setting(spec, "TELEGRAM_EMOJI") + ) + telegram_label = ( + telegram_label_override + or _localized_default(spec.telegram_labels, lang, None) + or spec.label + ) + telegram_emoji = telegram_emoji_override or spec.default_telegram_emoji + + if ( + not telegram_label_override + and not spec.telegram_labels + and translate + and spec.button_text_key + ): + telegram_label = translate(spec.button_text_key) + + return PaymentProviderPresentation( + webapp_label=webapp_label, + webapp_icon=webapp_icon, + telegram_label=telegram_label, + telegram_emoji=telegram_emoji, + telegram_customized=bool(telegram_label_override or telegram_emoji_override), + ) + + +def provider_telegram_button_text( + spec: PaymentProviderSpec, + settings: Any, + translate: Callable[[str], str], + *, + language: Optional[str] = None, +) -> str: + presentation = resolve_provider_presentation(spec, settings, language=language) + if presentation.telegram_emoji: + return f"{presentation.telegram_emoji} {presentation.telegram_label}".strip() + return presentation.telegram_label + + +def iter_unique_provider_routers(): + seen: set[int] = set() + for spec in PAYMENT_PROVIDER_SPECS: + router = spec.load_router() + if not router: + continue + marker = id(router) + if marker in seen: + continue + seen.add(marker) + yield router + + +def iter_service_keys() -> Iterable[str]: + seen: set[str] = set() + for spec in PAYMENT_PROVIDER_SPECS: + if not spec.service_key or spec.service_key in seen: + continue + seen.add(spec.service_key) + yield spec.service_key + + +def iter_service_specs() -> Iterable[PaymentProviderSpec]: + seen: set[str] = set() + for spec in PAYMENT_PROVIDER_SPECS: + if not spec.service_key or not spec.create_service or spec.service_key in seen: + continue + seen.add(spec.service_key) + yield spec + + +def build_provider_services(ctx: ServiceFactoryContext) -> Dict[str, Any]: + services: Dict[str, Any] = {} + for spec in iter_service_specs(): + services[spec.service_key] = spec.create_service(ctx) + return services + + +def provider_label_map(settings: Any = None, language: Optional[str] = None) -> Dict[str, str]: + labels: Dict[str, str] = {} + for spec in PAYMENT_PROVIDER_SPECS: + presentation = resolve_provider_presentation( + spec, + settings, + language=language, + ) + label = presentation.telegram_label if presentation.telegram_customized else spec.label + labels.setdefault(spec.provider_key, label) + for method in spec.method_ids: + labels.setdefault(method, label) + return labels + + +def provider_emoji_map(settings: Any = None) -> Dict[str, str]: + emojis: Dict[str, str] = {} + for spec in PAYMENT_PROVIDER_SPECS: + emoji = resolve_provider_presentation(spec, settings).telegram_emoji + emojis.setdefault(spec.provider_key, emoji) + for method in spec.method_ids: + emojis.setdefault(method, emoji) + return emojis + + +def pending_statuses() -> List[str]: + statuses = ["pending"] + for spec in PAYMENT_PROVIDER_SPECS: + if spec.pending_status not in statuses: + statuses.append(spec.pending_status) + return statuses diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py new file mode 100644 index 0000000..98233db --- /dev/null +++ b/backend/bot/payment_providers/severpay.py @@ -0,0 +1,444 @@ +import hashlib +import hmac +import json +import logging +import secrets +from typing import Any, Dict, Optional, Tuple + +from aiogram import Bot, F, Router, types +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings +from db.dal import payment_dal + +from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + lookup_payment_by_order_or_provider_id, + make_translator, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + notify_user_payment_failed, + parse_payment_callback, + payment_failed, + payment_unavailable, + post_json_request, + render_link_or_fail, +) + +_LOG = "severpay" + + +class SeverPayService(HttpClientMixin): + 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.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip( + "/" + ) + self.mid = settings.SEVERPAY_MID + self.token = settings.SEVERPAY_TOKEN or "" + self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}" + self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES + + self._init_http_client(total_timeout=15) + + self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token) + if not self.configured: + logging.warning( + "SeverPayService initialized but not fully configured. Payments disabled." + ) + + @staticmethod + def _format_amount(amount: float) -> str: + return f"{format_decimal_amount(amount):.2f}" + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + message = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return hmac.new( + self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]: + body: Dict[str, Any] = { + "mid": self.mid, + "salt": secrets.token_hex(8), + } + body.update(extra) + sorted_body = dict(sorted(body.items())) + sorted_body["sign"] = self._sign_payload(sorted_body) + return sorted_body + + def _validate_signature(self, payload: Dict[str, Any]) -> bool: + provided_sign = str(payload.get("sign") or "") + if not provided_sign or not self.token: + return False + # Webhook signatures are calculated on the original payload order (without sorting). + data = {k: v for k, v in payload.items() if k != "sign"} + expected_sign = self._sign_payload(data) + return hmac.compare_digest(provided_sign, expected_sign) + + async def create_payment( + self, + *, + payment_db_id: int, + user_id: int, + amount: float, + currency: Optional[str], + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("SeverPayService is not configured. Cannot create payment.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/payin/create" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + + body = { + "order_id": str(payment_db_id), + "amount": self._format_amount(amount), + "currency": currency_code, + "client_email": f"{user_id}@telegram.org", + "client_id": str(user_id), + "url_return": self.return_url, + } + + if self.lifetime_minutes: + body["lifetime"] = int(self.lifetime_minutes) + + success, response_data = await post_json_request( + session, + url, + body=self._build_signed_body(body), + log_prefix="SeverPay create_payment", + # SeverPay marks success with both HTTP 200 *and* the top-level ``status`` flag. + is_success=lambda status, data: status == 200 and bool((data or {}).get("status")), + ) + if success: + # SeverPay wraps the useful response inside ``data``; unwrap so callers + # don't have to know that detail. + return True, response_data.get("data") or response_data + return False, response_data + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) + + try: + payload = await request.json() + except Exception: + logging.exception("SeverPay webhook: failed to parse JSON.") + return web.json_response({"status": False, "msg": "bad_request"}, status=400) + + if not isinstance(payload, dict) or not self._validate_signature(payload): + logging.error("SeverPay webhook: invalid signature or payload.") + return web.json_response({"status": False, "msg": "invalid_signature"}, status=403) + + event_type = str(payload.get("type") or "").lower() + data = payload.get("data") or {} + + if event_type != "payin" or not isinstance(data, dict): + logging.warning("SeverPay webhook: unsupported event type '%s'", event_type) + return web.json_response({"status": True}) + + provider_payment_id = str(data.get("id") or data.get("uid") or "") + order_id_raw = data.get("order_id") + status = str(data.get("status") or "").lower() + + async with self.async_session_factory() as session: + payment = await lookup_payment_by_order_or_provider_id( + session, + order_id_raw=order_id_raw, + provider_payment_id=provider_payment_id or None, + ) + if not payment: + logging.error( + "SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", + order_id_raw, + provider_payment_id, + ) + return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) + + resolved_provider_id = provider_payment_id or str(payment.payment_id) + payment_months = 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" + ) + + if status == "success": + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_provider_id, + "succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "SeverPay webhook: failed to mark payment %s as succeeded.", + resolved_provider_id, + ) + return web.json_response( + {"status": False, "msg": "processing_error"}, status=500 + ) + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=payment.currency, + sale_mode=sale_mode, + months=payment_months, + traffic_amount=float(payment_months), + provider_subscription="severpay", + provider_notification="severpay", + db_user=payment.user, + log_prefix="SeverPay webhook", + ) + ) + if outcome is None: + return web.json_response( + {"status": False, "msg": "processing_error"}, status=500 + ) + return web.json_response({"status": True}) + + if status in {"fail", "decline"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_provider_id, + "failed", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "SeverPay webhook: failed to mark payment %s as failed.", + resolved_provider_id, + ) + return web.json_response( + {"status": False, "msg": "processing_error"}, status=500 + ) + await notify_user_payment_failed( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + payment=payment, + ) + return web.json_response({"status": True}) + + if status in {"process", "new"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_provider_id, + "pending_severpay", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "SeverPay webhook: failed to update pending status for %s.", + resolved_provider_id, + ) + return web.json_response({"status": True}) + + logging.warning( + "SeverPay webhook: unhandled status '%s' for payment %s", + status, + resolved_provider_id, + ) + return web.json_response({"status": True}) + + +async def severpay_webhook_route(request: web.Request) -> web.Response: + service: SeverPayService = request.app["severpay_service"] + return await service.webhook_route(request) + + +router = Router(name="user_subscription_payments_severpay_router") + + +@router.callback_query(F.data.startswith("pay_severpay:")) +async def pay_severpay_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + severpay_service: SeverPayService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not severpay_service or not severpay_service.configured: + logging.error("SeverPay service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_severpay data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + + currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_severpay", + description=payment_description, + months=parts.months, + provider="severpay", + sale_mode=parts.sale_mode, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "SeverPay: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await severpay_service.create_payment( + payment_db_id=payment_record.payment_id, + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + ) + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=first_value(response_data, "url", "payment_url", "paymentUrl"), + provider_payment_id=first_value(response_data, "id", "uid"), + log_prefix=_LOG, + ) + + +def create_service(ctx: ServiceFactoryContext) -> SeverPayService: + return SeverPayService( + bot=ctx.bot, + settings=ctx.settings, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + default_return_url=ctx.bot_username_for_default_return, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings = ctx.request.app["settings"] + service: SeverPayService = ctx.request.app["severpay_service"] + if not service or not service.configured: + return payment_unavailable() + + currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=currency, + status="pending_severpay", + provider="severpay", + ) + success, response_data = await service.create_payment( + payment_db_id=payment.payment_id, + user_id=ctx.user_id, + amount=ctx.price, + currency=currency, + ) + except Exception: + await ctx.session.rollback() + logging.exception("SeverPay WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=( + first_value(response_data, "url", "payment_url", "paymentUrl") if success else None + ), + provider_payment_id=first_value(response_data, "id", "uid"), + log_prefix="SeverPay", + ) + + +SPEC = PaymentProviderSpec( + id="severpay", + provider_key="severpay", + label="SeverPay", + webapp_label="SeverPay", + webapp_labels={"ru": "SeverPay", "en": "SeverPay"}, + webapp_icon="CreditCard", + telegram_labels={"ru": "SeverPay", "en": "SeverPay"}, + telegram_emoji="💳", + pending_status="pending_severpay", + enabled=lambda settings: settings.SEVERPAY_ENABLED, + service_key="severpay_service", + button_text_key="pay_with_severpay_button", + callback_prefix="pay_severpay", + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.severpay_webhook_path, + webhook_route=severpay_webhook_route, + create_webapp_payment=create_webapp_payment, +) diff --git a/backend/bot/payment_providers/shared/__init__.py b/backend/bot/payment_providers/shared/__init__.py new file mode 100644 index 0000000..ca88f5f --- /dev/null +++ b/backend/bot/payment_providers/shared/__init__.py @@ -0,0 +1,128 @@ +"""Cross-provider helpers. + +Provider modules at ``bot.payment_providers.`` import the building +blocks they need from here. Nothing in this package depends on any +specific provider — it is the layer below them. +""" + +from .callbacks import ( + PaymentCallbackParts, + describe_payment, + edit_or_answer, + notify_callback_parse_error, + notify_payment_gateway_failure, + notify_payment_record_failure, + notify_service_unavailable, + parse_payment_callback, + payment_link_message_text, + render_link_or_fail, + render_payment_link, + safe_callback_answer, + safe_mark_failed_creation, + safe_store_provider_payment_id, +) +from .common import ( + PaymentRecordAmounts, + Translator, + build_payment_description, + build_payment_record_payload, + create_base_payment_record, + create_webapp_payment_record, + decimal_amounts_equal, + format_decimal_amount, + format_human_units, + format_number_for_payload, + json_error, + make_translator, + mark_payment_failed_creation, + payment_failed, + payment_link_response, + payment_record_amounts, + payment_unavailable, + sale_mode_base, + sale_mode_is_hwid_devices, + sale_mode_is_traffic, + sale_mode_tariff_key, +) +from .http_client import ( + HttpClientMixin, + SuccessCheck, + first_value, + http_ok, + post_json_request, +) +from .success import ( + PaymentSuccessOutcome, + PaymentSuccessRequest, + SuccessMessage, + build_success_message, + finalize_successful_payment, + is_traffic_sale_base, + notify_admins_payment_received, + resolve_inviter_name, + resolve_user_language, + send_success_message_to_user, +) +from .webapp import finalize_webapp_link_payment +from .webhooks import ( + coerce_payment_db_id, + lookup_payment_by_order_or_provider_id, + notify_user_payment_failed, +) + +__all__ = [ + "HttpClientMixin", + "PaymentCallbackParts", + "PaymentRecordAmounts", + "PaymentSuccessOutcome", + "PaymentSuccessRequest", + "SuccessCheck", + "SuccessMessage", + "Translator", + "build_payment_description", + "build_payment_record_payload", + "build_success_message", + "coerce_payment_db_id", + "create_base_payment_record", + "create_webapp_payment_record", + "decimal_amounts_equal", + "describe_payment", + "edit_or_answer", + "finalize_successful_payment", + "finalize_webapp_link_payment", + "first_value", + "format_decimal_amount", + "format_human_units", + "format_number_for_payload", + "http_ok", + "is_traffic_sale_base", + "json_error", + "lookup_payment_by_order_or_provider_id", + "make_translator", + "mark_payment_failed_creation", + "notify_admins_payment_received", + "notify_callback_parse_error", + "notify_payment_gateway_failure", + "notify_payment_record_failure", + "notify_service_unavailable", + "notify_user_payment_failed", + "parse_payment_callback", + "payment_failed", + "payment_link_message_text", + "payment_link_response", + "payment_record_amounts", + "payment_unavailable", + "post_json_request", + "render_link_or_fail", + "render_payment_link", + "resolve_inviter_name", + "resolve_user_language", + "safe_callback_answer", + "safe_mark_failed_creation", + "safe_store_provider_payment_id", + "sale_mode_base", + "sale_mode_is_hwid_devices", + "sale_mode_is_traffic", + "sale_mode_tariff_key", + "send_success_message_to_user", +] diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py new file mode 100644 index 0000000..c65ad31 --- /dev/null +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from aiogram import 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 db.dal import payment_dal +from db.models import Payment + +from .common import ( + Translator, + build_payment_description, + format_human_units, + mark_payment_failed_creation, + sale_mode_base, +) + + +@dataclass(frozen=True) +class PaymentCallbackParts: + months: float + price: float + sale_mode: str + + @property + def human_value(self) -> str: + return format_human_units(self.months) + + @property + def sale_base(self) -> str: + return sale_mode_base(self.sale_mode) + + +def parse_payment_callback(callback_data: str) -> Optional[PaymentCallbackParts]: + """Parse the ``:::`` payload all providers use. + + Returns ``None`` if the payload doesn't have the expected shape — callers + answer with ``error_try_again`` in that case. + """ + try: + _, data_payload = callback_data.split(":", 1) + parts = data_payload.split(":") + months = float(parts[0]) + price = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" + except (ValueError, IndexError): + return None + return PaymentCallbackParts(months=months, price=price, sale_mode=sale_mode) + + +async def safe_callback_answer( + callback: types.CallbackQuery, + text: Optional[str] = None, + *, + show_alert: bool = False, +) -> None: + """``callback.answer`` that never raises (Telegram occasionally 400s).""" + try: + if text is None: + await callback.answer() + else: + await callback.answer(text, show_alert=show_alert) + except Exception: + pass + + +async def edit_or_answer( + callback: types.CallbackQuery, + text: str, + *, + reply_markup=None, + disable_web_page_preview: bool = False, + log_prefix: str = "payment_providers", +) -> None: + """Edit the callback message if possible, else send a fresh reply.""" + if not callback.message: + return + try: + await callback.message.edit_text( + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + return + except Exception as exc: + logging.warning("%s: failed to edit message (%s), sending new one.", log_prefix, exc) + try: + await callback.message.answer( + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + except Exception: + pass + + +def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str: + """Shortcut around ``build_payment_description`` for callback usage.""" + return build_payment_description( + translator, + months=parts.months, + sale_mode=parts.sale_mode, + human_value=parts.human_value, + ) + + +def payment_link_message_text( + translator: Translator, + parts: PaymentCallbackParts, + *, + lead_text: Optional[str] = None, +) -> str: + """Build the ``payment_link_message`` text (with optional lead block).""" + traffic_like = sale_mode_base(parts.sale_mode) in { + "traffic", + "traffic_package", + "topup", + "premium_topup", + } + key = ( + "payment_link_message_traffic" + if traffic_like + else "payment_link_message" + ) + body = translator( + key, + months=int(parts.months), + traffic_gb=parts.human_value, + ) + if lead_text: + return f"{lead_text}\n\n{body}" + return body + + +async def render_payment_link( + callback: types.CallbackQuery, + *, + translator: Translator, + current_lang: str, + i18n: Optional[JsonI18n], + parts: PaymentCallbackParts, + payment_url: str, + lead_text: Optional[str] = None, + back_text_key: str = "back_to_payment_methods_button", + log_prefix: str = "payment_providers", +) -> None: + """Show the payment link with the standard back button and shared fallbacks.""" + text = payment_link_message_text(translator, parts, lead_text=lead_text) + keyboard = get_payment_url_keyboard( + payment_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{parts.human_value}", + back_text_key=back_text_key, + ) + await edit_or_answer( + callback, + text, + reply_markup=keyboard, + log_prefix=log_prefix, + ) + await safe_callback_answer(callback) + + +async def notify_service_unavailable( + callback: types.CallbackQuery, + translator: Translator, +) -> None: + """Render the standard ``payment_service_unavailable`` UX.""" + await safe_callback_answer( + callback, + translator("payment_service_unavailable_alert"), + show_alert=True, + ) + if callback.message: + try: + await callback.message.edit_text(translator("payment_service_unavailable")) + except Exception: + pass + + +async def notify_callback_parse_error( + callback: types.CallbackQuery, + translator: Translator, +) -> None: + """The 4-line "callback payload looked wrong" guard every provider repeats.""" + await safe_callback_answer(callback, translator("error_try_again"), show_alert=True) + + +async def notify_payment_record_failure( + callback: types.CallbackQuery, + translator: Translator, +) -> None: + """Both error_creating_payment_record + error_try_again shown after DB failure.""" + if callback.message: + try: + await callback.message.edit_text(translator("error_creating_payment_record")) + except Exception: + pass + await safe_callback_answer(callback, translator("error_try_again"), show_alert=True) + + +async def notify_payment_gateway_failure( + callback: types.CallbackQuery, + translator: Translator, +) -> None: + """``error_payment_gateway`` shown both inline and as alert.""" + if callback.message: + try: + await callback.message.edit_text(translator("error_payment_gateway")) + except Exception: + pass + await safe_callback_answer( + callback, + translator("error_payment_gateway"), + show_alert=True, + ) + + +async def safe_store_provider_payment_id( + session: AsyncSession, + payment: Payment, + *, + provider_payment_id: str, + new_status: Optional[str] = None, + log_prefix: str, +) -> bool: + """Persist ``(provider_payment_id, status)`` on the payment with rollback-on-fail. + + Returns True on success; logs and rolls back on failure. ``new_status`` + defaults to the payment's existing status (used after a successful API call + that doesn't change the pending state). + """ + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + str(provider_payment_id), + new_status or payment.status, + ) + await session.commit() + return True + except Exception: + await session.rollback() + logging.exception( + "%s: failed to store provider payment id for payment %s.", + log_prefix, + payment.payment_id, + ) + return False + + +async def safe_mark_failed_creation( + session: AsyncSession, + payment: Payment, + *, + log_prefix: str, +) -> None: + """Mark the payment as ``failed_creation``; swallow + log on failure.""" + try: + await mark_payment_failed_creation(session, payment.payment_id) + except Exception: + await session.rollback() + logging.exception( + "%s: failed to mark payment %s as failed_creation.", + log_prefix, + payment.payment_id, + ) + + +async def render_link_or_fail( + callback: types.CallbackQuery, + *, + translator: Translator, + current_lang: str, + i18n: Optional[JsonI18n], + parts: "PaymentCallbackParts", + session: AsyncSession, + payment: Payment, + api_success: bool, + payment_url: Optional[str], + provider_payment_id: Optional[str] = None, + new_status: Optional[str] = None, + lead_text: Optional[str] = None, + log_prefix: str, +) -> None: + """Finalize the link-based callback flow after the provider API responded. + + Persists the provider payment id (when one was returned), shows the + payment link, or falls through to ``error_payment_gateway`` and marks the + payment as ``failed_creation``. Every link-style provider used to inline + this same sequence. + """ + if api_success and provider_payment_id: + await safe_store_provider_payment_id( + session, + payment, + provider_payment_id=provider_payment_id, + new_status=new_status, + log_prefix=log_prefix, + ) + + if api_success and payment_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=payment_url, + lead_text=lead_text, + log_prefix=log_prefix, + ) + return + + await safe_mark_failed_creation(session, payment, log_prefix=log_prefix) + await notify_payment_gateway_failure(callback, translator) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py new file mode 100644 index 0000000..be32e7a --- /dev/null +++ b/backend/bot/payment_providers/shared/common.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Callable, Optional + +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession + +from db.dal import payment_dal +from db.models import Payment + +from ..base import WebAppPaymentContext + +Translator = Callable[..., str] + + +def make_translator(i18n: Any, language: str) -> Translator: + """Return a ``_(key, **kw)`` callable that falls back to the key when i18n is absent.""" + + def _(key: str, **kwargs: Any) -> str: + if i18n is None: + return key + return i18n.gettext(language, key, **kwargs) + + return _ + + +def format_decimal_amount(amount: Any, places: int = 2) -> Decimal: + """Quantize ``amount`` to the given decimal places using bank rounding.""" + return Decimal(str(amount)).quantize(Decimal(10) ** -places, rounding=ROUND_HALF_UP) + + +def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool: + """True when both values round to the same fixed-point representation.""" + return format_decimal_amount(left, places) == format_decimal_amount(right, places) + + +def format_human_units(value: Any) -> str: + """Render numeric units the way the UI expects: integers w/o decimals, floats with %g.""" + numeric = float(value) + return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}" + + +def build_payment_description( + translator: Translator, + *, + months: Any, + sale_mode: str, + human_value: Optional[str] = None, +) -> str: + """Render the standard user-visible payment description. + + Mirrors the branching every callback handler used to repeat + (traffic / hwid_devices / subscription). + """ + base = sale_mode_base(sale_mode) + if base in {"traffic", "traffic_package", "topup", "premium_topup"}: + return translator( + "payment_description_traffic", + traffic_gb=human_value if human_value is not None else format_human_units(months), + ) + if base in {"hwid_device", "hwid_devices"}: + return translator("payment_description_hwid_devices", count=int(float(months))) + return translator("payment_description_subscription", months=int(float(months))) + + +def build_payment_record_payload( + *, + user_id: int, + amount: float, + currency: str, + status: str, + description: str, + months: Any, + provider: str, + sale_mode: str, +) -> dict: + """Assemble the payment-record dict that every callback handler used to inline. + + For the ``traffic`` sale modes, ``purchased_gb`` is taken from ``months`` + (callbacks encode the GB amount in the ``months`` slot); webapp creators + use the ``payment_record_amounts`` helper directly to split the two. + """ + base = sale_mode_base(sale_mode) + is_traffic = sale_mode_is_traffic(sale_mode) + is_hwid = sale_mode_is_hwid_devices(sale_mode) + return { + "user_id": user_id, + "amount": amount, + "currency": currency, + "status": status, + "description": description, + "subscription_duration_months": int(float(months)) if base == "subscription" else None, + "provider": provider, + "sale_mode": sale_mode, + "tariff_key": sale_mode_tariff_key(sale_mode), + "purchased_gb": float(months) if is_traffic else None, + "purchased_hwid_devices": int(float(months)) if is_hwid else None, + } + + +@dataclass(frozen=True) +class PaymentRecordAmounts: + months: int + purchased_gb: Optional[float] + purchased_hwid_devices: Optional[int] + tariff_key: Optional[str] + traffic_sale: bool + hwid_devices_sale: bool + + +def sale_mode_base(sale_mode: str) -> str: + return str(sale_mode or "").split("@", 1)[0].split("|", 1)[0] + + +def sale_mode_is_traffic(sale_mode: str) -> bool: + return sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"} + + +def sale_mode_is_hwid_devices(sale_mode: str) -> bool: + return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} + + +def sale_mode_tariff_key(sale_mode: str) -> Optional[str]: + return str(sale_mode or "").split("@", 1)[1] if "@" in str(sale_mode or "") else None + + +def format_number_for_payload(value: Any) -> str: + value_float = float(value) + return str(int(value_float)) if value_float.is_integer() else f"{value_float:g}" + + +def payment_record_amounts( + *, + months: Any, + sale_mode: str, + traffic_gb: Optional[float] = None, +) -> PaymentRecordAmounts: + traffic_sale = sale_mode_is_traffic(sale_mode) + hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) + units = traffic_gb if traffic_sale and traffic_gb is not None else months + return PaymentRecordAmounts( + months=int(float(units)) if traffic_sale else int(float(months)), + purchased_gb=float(units) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + tariff_key=sale_mode_tariff_key(sale_mode), + traffic_sale=traffic_sale, + hwid_devices_sale=hwid_devices_sale, + ) + + +def json_error(status: int, code: str, message: str) -> web.Response: + return web.json_response({"ok": False, "error": code, "message": message}, status=status) + + +def payment_unavailable() -> web.Response: + return json_error(400, "payment_unavailable", "Payment method unavailable") + + +def payment_failed(message: str = "Failed to create payment") -> web.Response: + return json_error(502, "payment_failed", message) + + +def payment_link_response( + *, + payment_url: str, + payment_id: Optional[int], + action: str = "open_link", +) -> web.Response: + return web.json_response( + { + "ok": True, + "action": action, + "payment_url": payment_url, + "payment_id": payment_id, + } + ) + + +async def create_base_payment_record( + session: AsyncSession, + *, + user_id: int, + amount: float, + currency: str, + status: str, + description: str, + months: int, + provider: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, +) -> Payment: + payment = await payment_dal.create_payment_record( + session, + { + "user_id": user_id, + "amount": amount, + "currency": currency, + "status": status, + "description": description, + "subscription_duration_months": months, + "provider": provider, + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, + }, + ) + await session.commit() + return payment + + +async def create_webapp_payment_record( + ctx: WebAppPaymentContext, + *, + amount: float, + currency: str, + status: str, + provider: str, +) -> Payment: + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + ) + return await create_base_payment_record( + ctx.session, + user_id=ctx.user_id, + amount=amount, + currency=currency, + status=status, + description=ctx.description, + months=amounts.months, + provider=provider, + sale_mode=ctx.sale_mode, + tariff_key=amounts.tariff_key, + purchased_gb=amounts.purchased_gb, + purchased_hwid_devices=amounts.purchased_hwid_devices, + ) + + +async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None: + await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation") + await session.commit() diff --git a/backend/bot/payment_providers/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py new file mode 100644 index 0000000..c5dbd6d --- /dev/null +++ b/backend/bot/payment_providers/shared/http_client.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Callable, Dict, Mapping, Optional, Tuple + +from aiohttp import ClientSession, ClientTimeout + +SuccessCheck = Callable[[int, Any], bool] + + +def http_ok(status: int, _body: Any) -> bool: + """Default success criterion — HTTP 200 with any body.""" + return status == 200 + + +async def post_json_request( + session: ClientSession, + url: str, + *, + body: Any, + headers: Optional[Mapping[str, str]] = None, + log_prefix: str, + is_success: SuccessCheck = http_ok, +) -> Tuple[bool, Dict[str, Any]]: + """Centralized JSON-POST every HTTP-API provider used to inline ~25 lines for. + + On transport failure, JSON decode failure, or rejected ``is_success`` check, + returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers + can decide what to do (typically: mark the payment as ``failed_creation``). + """ + try: + async with session.post( + url, + json=body, + headers=dict(headers) if headers else None, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("%s: invalid JSON response: %s", log_prefix, response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + if not is_success(response.status, response_data): + logging.error( + "%s: API returned error (status=%s, body=%s)", + log_prefix, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + return True, response_data + except Exception as exc: + logging.exception("%s: request failed.", log_prefix) + return False, {"message": str(exc)} + + +def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]: + """Return the first non-empty value among ``keys`` (cast to ``str``).""" + if not data: + return None + for key in keys: + value = data.get(key) + if value: + return str(value) + return None + + +class HttpClientMixin: + """Shared lazy ``aiohttp.ClientSession`` lifecycle for provider services. + + Each subclass calls ``self._init_http_client(total_timeout=...)`` from + ``__init__`` and inherits ``_get_session`` / ``close``. The session is + created on first use and recreated transparently if it was closed. + """ + + _timeout: ClientTimeout + _session: Optional[ClientSession] + + def _init_http_client(self, *, total_timeout: float = 20.0) -> None: + self._timeout = ClientTimeout(total=total_timeout) + self._session = None + + 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() diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py new file mode 100644 index 0000000..31c673f --- /dev/null +++ b/backend/bot/payment_providers/shared/success.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Optional + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.services.notification_service import NotificationService +from bot.utils.config_link import prepare_config_links +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display +from db.dal import payment_dal, user_dal +from db.models import Payment, User + +from .common import Translator, format_human_units, make_translator, sale_mode_base + +_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"} + + +def is_traffic_sale_base(sale_base: str) -> bool: + return sale_base in _TRAFFIC_MODES + + +async def resolve_user_language( + session: AsyncSession, + *, + user_id: int, + db_user: Optional[User], + settings: Any, +) -> tuple[Optional[User], str]: + """Return the loaded user and the language to use for messaging.""" + if db_user is None: + db_user = await user_dal.get_user_by_id(session, user_id) + language = ( + db_user.language_code + if db_user and db_user.language_code + else settings.DEFAULT_LANGUAGE + ) + return db_user, language + + +async def resolve_inviter_name( + session: AsyncSession, + translator: Translator, + db_user: Optional[User], +) -> str: + """Return a display name for the user's inviter, or the localized placeholder.""" + placeholder = translator("friend_placeholder") + if not db_user or not db_user.referred_by_id: + return placeholder + inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) + if not inviter: + return placeholder + if inviter.first_name: + safe_name = sanitize_display_name(inviter.first_name) + if safe_name: + return safe_name + if inviter.username: + return username_for_display(inviter.username, with_at=False) + return placeholder + + +@dataclass +class SuccessMessage: + """Inputs for ``build_success_message``.""" + + translator: Translator + sale_mode: str + months: Any + base_end_date: Optional[datetime] + final_end_date: Optional[datetime] + config_link_text: str + applied_referee_bonus_days: int = 0 + applied_promo_bonus_days: int = 0 + inviter_name: Optional[str] = None + fallback_date_text: str = "" + + +def _fmt_date(dt: Optional[datetime], fallback: str) -> str: + return dt.strftime("%Y-%m-%d") if dt else fallback + + +def build_success_message(payload: SuccessMessage) -> str: + """Render the post-payment user-facing text. + + Picks one of: ``payment_successful_traffic_full`` / + ``payment_successful_with_referral_bonus_full`` / + ``payment_successful_with_promo_full`` / ``payment_successful_full``. + """ + base = sale_mode_base(payload.sale_mode) + _ = payload.translator + end_text = _fmt_date(payload.final_end_date, payload.fallback_date_text) + + if is_traffic_sale_base(base): + return _( + "payment_successful_traffic_full", + traffic_gb=format_human_units(payload.months), + end_date=end_text, + config_link=payload.config_link_text, + ) + if payload.applied_referee_bonus_days and payload.final_end_date: + base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text) + return _( + "payment_successful_with_referral_bonus_full", + months=payload.months, + base_end_date=base_end_text, + bonus_days=payload.applied_referee_bonus_days, + final_end_date=end_text, + inviter_name=payload.inviter_name or _("friend_placeholder"), + config_link=payload.config_link_text, + ) + if payload.applied_promo_bonus_days and payload.final_end_date: + return _( + "payment_successful_with_promo_full", + months=payload.months, + bonus_days=payload.applied_promo_bonus_days, + end_date=end_text, + config_link=payload.config_link_text, + ) + return _( + "payment_successful_full", + months=payload.months, + end_date=end_text, + config_link=payload.config_link_text, + ) + + +async def send_success_message_to_user( + *, + bot: Bot, + user_id: int, + text: str, + language: str, + i18n: Any, + settings: Any, + config_link_display: Optional[str], + connect_button_url: Optional[str], + include_keyboard: bool = True, + log_prefix: str = "payment_providers", +) -> None: + """Send the rendered success text with the standard connect keyboard.""" + markup = None + if include_keyboard: + markup = get_connect_and_main_keyboard( + language, + i18n, + settings, + config_link_display, + connect_button_url=connect_button_url, + preserve_message=True, + ) + try: + await bot.send_message( + user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception: + logging.exception("%s: failed to notify user %s.", log_prefix, user_id) + + +async def notify_admins_payment_received( + *, + bot: Bot, + settings: Any, + i18n: Any, + user_id: int, + amount: float, + currency: str, + months_for_admin: int, + traffic_gb_for_admin: Optional[float], + payment_provider: str, + username: Optional[str], + traffic_is_premium: bool, + tariff_key: Optional[str], + log_prefix: str = "payment_providers", +) -> None: + """Push the standard ``notify_payment_received`` to the admin log channel.""" + try: + notification_service = NotificationService(bot, settings, i18n) + await notification_service.notify_payment_received( + user_id=user_id, + amount=amount, + currency=currency, + months=months_for_admin, + traffic_gb=traffic_gb_for_admin, + payment_provider=payment_provider, + username=username, + traffic_is_premium=traffic_is_premium, + tariff_key=tariff_key, + ) + except Exception: + logging.exception("%s: failed to notify admins.", log_prefix) + + +@dataclass +class PaymentSuccessRequest: + """All the inputs ``finalize_successful_payment`` needs.""" + + bot: Bot + settings: Any + i18n: Any + session: AsyncSession + subscription_service: Any + referral_service: Any + + payment: Payment + user_id: int + amount: float + currency: str + + sale_mode: str + months: Any + traffic_amount: Optional[float] + + provider_subscription: str + provider_notification: str + + db_user: Optional[User] = None + log_prefix: str = "payment_providers" + activation_extra_kwargs: dict = field(default_factory=dict) + skip_keyboard: bool = False + text_prefix: Optional[str] = None + + +@dataclass +class PaymentSuccessOutcome: + activation: Optional[dict] + referral_bonus: Optional[dict] + final_end_date: Optional[datetime] + applied_referee_bonus_days: int + applied_promo_bonus_days: int + db_user: Optional[User] + language: str + + +async def finalize_successful_payment( + req: PaymentSuccessRequest, +) -> Optional[PaymentSuccessOutcome]: + """Activate the subscription, apply referral bonus, notify user + admins. + + Returns ``None`` if the activation pipeline failed mid-way (errors are + logged and the session is rolled back). On success returns an outcome + object so callers can drive extra side-effects (e.g. yookassa LKNPD + receipts) using the same activation result. + """ + base = sale_mode_base(req.sale_mode) + is_subscription = base == "subscription" + is_traffic = is_traffic_sale_base(base) + + activation_months = ( + int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months)) + ) + traffic_gb_for_activation = ( + float(req.traffic_amount or req.months) if is_traffic else None + ) + + try: + activation = await req.subscription_service.activate_subscription( + req.session, + req.user_id, + activation_months, + req.amount, + req.payment.payment_id, + provider=req.provider_subscription, + sale_mode=req.sale_mode, + traffic_gb=traffic_gb_for_activation, + **req.activation_extra_kwargs, + ) + referral_bonus = None + if is_subscription: + referral_bonus = await req.referral_service.apply_referral_bonuses_for_payment( + req.session, + req.user_id, + activation_months or 1, + current_payment_db_id=req.payment.payment_id, + skip_if_active_before_payment=False, + ) + await req.session.commit() + except Exception: + await req.session.rollback() + logging.exception( + "%s: failed to activate subscription for payment %s.", + req.log_prefix, + req.payment.payment_id, + ) + return None + + db_user, language = await resolve_user_language( + req.session, + user_id=req.user_id, + db_user=req.db_user, + settings=req.settings, + ) + translator = make_translator(req.i18n, language) + + raw_config_link = activation.get("subscription_url") if activation else None + config_link_display, connect_button_url = await prepare_config_links( + req.settings, raw_config_link + ) + config_link_text = config_link_display or translator("config_link_not_available") + + base_end_date = activation.get("end_date") if activation else None + final_end_date = base_end_date + applied_referee_bonus_days = 0 + applied_promo_bonus_days = ( + activation.get("applied_promo_bonus_days", 0) if activation else 0 + ) + + inviter_name: Optional[str] = None + if referral_bonus and referral_bonus.get("referee_new_end_date"): + final_end_date = referral_bonus["referee_new_end_date"] + applied_referee_bonus_days = referral_bonus.get("referee_bonus_applied_days", 0) or 0 + inviter_name = await resolve_inviter_name(req.session, translator, db_user) + + success_text = build_success_message( + SuccessMessage( + translator=translator, + sale_mode=req.sale_mode, + months=( + activation_months + if is_subscription + else format_human_units(req.traffic_amount or req.months) + ), + base_end_date=base_end_date, + final_end_date=final_end_date, + config_link_text=config_link_text, + applied_referee_bonus_days=applied_referee_bonus_days, + applied_promo_bonus_days=applied_promo_bonus_days, + inviter_name=inviter_name, + ) + ) + if req.text_prefix: + success_text = f"{req.text_prefix}\n{success_text}" + + await send_success_message_to_user( + bot=req.bot, + user_id=req.user_id, + text=success_text, + language=language, + i18n=req.i18n, + settings=req.settings, + config_link_display=config_link_display, + connect_button_url=connect_button_url, + include_keyboard=not req.skip_keyboard, + log_prefix=req.log_prefix, + ) + + refreshed_payment = await payment_dal.get_payment_by_db_id( + req.session, req.payment.payment_id + ) + tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None) + + await notify_admins_payment_received( + bot=req.bot, + settings=req.settings, + i18n=req.i18n, + user_id=req.user_id, + amount=req.amount, + currency=req.currency, + months_for_admin=activation_months if is_subscription else 0, + traffic_gb_for_admin=traffic_gb_for_activation, + payment_provider=req.provider_notification, + username=db_user.username if db_user else None, + traffic_is_premium=base == "premium_topup", + tariff_key=tariff_key, + log_prefix=req.log_prefix, + ) + + return PaymentSuccessOutcome( + activation=activation, + referral_bonus=referral_bonus, + final_end_date=final_end_date, + applied_referee_bonus_days=applied_referee_bonus_days, + applied_promo_bonus_days=applied_promo_bonus_days, + db_user=db_user, + language=language, + ) diff --git a/backend/bot/payment_providers/shared/webapp.py b/backend/bot/payment_providers/shared/webapp.py new file mode 100644 index 0000000..d8f1c23 --- /dev/null +++ b/backend/bot/payment_providers/shared/webapp.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession + +from db.dal import payment_dal +from db.models import Payment + +from .common import mark_payment_failed_creation, payment_failed, payment_link_response + + +async def finalize_webapp_link_payment( + *, + session: AsyncSession, + payment: Payment, + api_success: bool, + payment_url: Optional[str], + provider_payment_id: Optional[str] = None, + new_status: Optional[str] = None, + log_prefix: str, +) -> web.Response: + """The trailing "persist id → return link or fail" used by every link-style webapp creator. + + Mirrors :func:`render_link_or_fail` but for the webapp HTTP context: instead + of editing a Telegram message it returns either ``payment_link_response`` + or ``payment_failed``. Provider modules just call: + + payment = await create_webapp_payment_record(ctx, ...) + success, data = await service.create_xxx(...) + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(data, "url", "payment_url"), + provider_payment_id=first_value(data, "id"), + log_prefix="Wata", + ) + """ + if api_success and provider_payment_id: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + str(provider_payment_id), + new_status or payment.status, + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "%s: failed to persist provider payment id for payment %s.", + log_prefix, + payment.payment_id, + ) + + if not payment_url: + try: + await mark_payment_failed_creation(session, payment.payment_id) + except Exception: + await session.rollback() + logging.exception( + "%s: failed to mark payment %s as failed_creation.", + log_prefix, + payment.payment_id, + ) + return payment_failed() + + return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id) diff --git a/backend/bot/payment_providers/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py new file mode 100644 index 0000000..0291663 --- /dev/null +++ b/backend/bot/payment_providers/shared/webhooks.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from typing import Any, Optional + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from db.dal import payment_dal, user_dal +from db.models import Payment + +from .common import make_translator + + +def coerce_payment_db_id(order_id_raw: Any) -> Optional[int]: + """Pull a numeric DB id out of a webhook's ``orderId``/``order_id`` field.""" + if isinstance(order_id_raw, int): + return order_id_raw + if isinstance(order_id_raw, str) and order_id_raw.isdigit(): + return int(order_id_raw) + return None + + +async def lookup_payment_by_order_or_provider_id( + session: AsyncSession, + *, + order_id_raw: Any = None, + provider_payment_id: Optional[str] = None, +) -> Optional[Payment]: + """Find a payment by DB id first, fall back to provider id. + + Returns ``None`` so callers stay in charge of the not-found response. + """ + payment_db_id = coerce_payment_db_id(order_id_raw) + payment: Optional[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 provider_payment_id: + payment = await payment_dal.get_payment_by_provider_payment_id( + session, provider_payment_id + ) + return payment + + +async def notify_user_payment_failed( + *, + bot: Bot, + settings: Any, + i18n: Any, + session: AsyncSession, + payment: Payment, + message_key: str = "payment_failed", +) -> None: + """Send the localized ``payment_failed`` text to the user; never raises.""" + db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + language = ( + db_user.language_code + if db_user and db_user.language_code + else settings.DEFAULT_LANGUAGE + ) + translator = make_translator(i18n, language) + try: + await bot.send_message(payment.user_id, translator(message_key)) + except Exception: + logging.exception( + "Webhook helper: failed to notify user %s about %s.", + payment.user_id, + message_key, + ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py new file mode 100644 index 0000000..3886551 --- /dev/null +++ b/backend/bot/payment_providers/stars.py @@ -0,0 +1,369 @@ +import logging +from typing import Optional + +from aiogram import Bot, F, Router, types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ServiceFactoryContext, + WebAppPaymentContext, +) +from .shared import ( + PaymentSuccessRequest, + create_webapp_payment_record, + describe_payment, + finalize_successful_payment, + format_number_for_payload, + make_translator, + notify_callback_parse_error, + notify_service_unavailable, + parse_payment_callback, + payment_failed, + payment_record_amounts, + payment_unavailable, + safe_callback_answer, + sale_mode_base, + sale_mode_tariff_key, +) + + +class StarsService: + def __init__( + self, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + subscription_service: SubscriptionService, + referral_service: ReferralService, + ): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.subscription_service = subscription_service + self.referral_service = referral_service + + async def create_invoice( + self, + session: AsyncSession, + user_id: int, + months: int, + stars_price: int, + description: str, + sale_mode: str = "subscription", + ) -> Optional[int]: + sale_base = sale_mode_base(sale_mode) + is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} + payment_record_data = { + "user_id": user_id, + "amount": float(stars_price), + "currency": "XTR", + "status": "pending_stars", + "description": description, + "subscription_duration_months": int(months) if sale_base == "subscription" else None, + "provider": "telegram_stars", + "sale_mode": sale_mode, + "tariff_key": sale_mode_tariff_key(sale_mode), + "purchased_gb": float(months) if is_traffic else None, + } + try: + db_payment_record = await payment_dal.create_payment_record( + session, payment_record_data + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to create stars payment record") + return None + + payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}" + prices = [LabeledPrice(label=description, amount=stars_price)] + try: + await self.bot.send_invoice( + chat_id=user_id, + title=description, + description=description, + payload=payload, + # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. + provider_token="", + currency="XTR", + prices=prices, + ) + return db_payment_record.payment_id + except Exception: + logging.exception("Failed to send Telegram Stars invoice") + return None + + async def process_successful_payment( + self, + session: AsyncSession, + message: types.Message, + payment_db_id: int, + months: int, + stars_amount: int, + i18n_data: dict, + sale_mode: str = "subscription", + ) -> None: + try: + payment_record = await payment_dal.update_provider_payment_and_status( + session, + payment_db_id, + message.successful_payment.provider_payment_charge_id, + "succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to update stars payment record %s", payment_db_id) + return + + target_user_id = ( + int(payment_record.user_id) + if payment_record and payment_record.user_id is not None + else int(message.from_user.id) + ) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error("Stars: payment %s vanished after status update.", payment_db_id) + return + + await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=i18n_data.get("i18n_instance") or self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=target_user_id, + amount=float(stars_amount), + currency="XTR", + sale_mode=sale_mode, + months=months, + traffic_amount=float(months), + provider_subscription="telegram_stars", + provider_notification="stars", + log_prefix="Stars", + ) + ) + + +router = Router(name="user_subscription_payments_stars_router") + + +@router.callback_query(F.data.startswith("pay_stars:")) +async def pay_stars_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not settings.STARS_ENABLED: + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + await notify_callback_parse_error(callback, translator) + return + + # ``parts.price`` for the Stars callback is the integer Stars price. + stars_price = int(parts.price) + payment_description = describe_payment(translator, parts) + + payment_db_id = await stars_service.create_invoice( + session=session, + user_id=callback.from_user.id, + months=parts.months, + stars_price=stars_price, + description=payment_description, + sale_mode=parts.sale_mode, + ) + + if payment_db_id: + sale_base = parts.sale_base + text_key = ( + "payment_invoice_sent_message_traffic" + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} + else "payment_invoice_sent_message" + ) + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=translator("back_to_payment_methods_button"), + callback_data=f"subscribe_period:{parts.human_value}", + ) + ] + ] + ) + try: + await callback.message.edit_text( + translator( + text_key, + months=int(parts.months), + traffic_gb=parts.human_value, + ), + reply_markup=markup, + ) + except Exception: + logging.warning("Stars payment: failed to show invoice info message") + await safe_callback_answer(callback) + return + + await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True) + + +@router.pre_checkout_query() +async def handle_pre_checkout_query(query: types.PreCheckoutQuery): + try: + await query.answer(ok=True) + except Exception: + # Nothing else to do here; Telegram will show an error if not answered + pass + + +@router.message(F.successful_payment) +async def handle_successful_stars_payment( + message: types.Message, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + payload = ( + message.successful_payment.invoice_payload if message and message.successful_payment else "" + ) + try: + parts = (payload or "").split(":") + payment_db_id = int(parts[0]) + months = float(parts[1]) if len(parts) > 1 else 0 + sale_mode = parts[2] if len(parts) > 2 else "subscription" + except Exception: + return + + stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 + await stars_service.process_successful_payment( + session=session, + message=message, + payment_db_id=payment_db_id, + months=months, + stars_amount=stars_amount, + i18n_data=i18n_data, + sale_mode=sale_mode, + ) + + +def create_service(ctx: ServiceFactoryContext) -> StarsService: + return StarsService( + ctx.bot, + ctx.settings, + ctx.i18n, + ctx.subscription_service, + ctx.referral_service, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + if ctx.stars_price is None: + return payment_unavailable() + bot = ctx.request.app["bot"] + try: + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + ) + payment = await create_webapp_payment_record( + ctx, + amount=float(ctx.stars_price), + currency="XTR", + status="pending_stars", + provider="telegram_stars", + ) + payload_units = amounts.purchased_gb if amounts.traffic_sale else ctx.months + payload = ( + f"{payment.payment_id}:{format_number_for_payload(payload_units)}:{ctx.sale_mode}" + ) + prices = [LabeledPrice(label=ctx.description, amount=ctx.stars_price)] + create_invoice_link = getattr(bot, "create_invoice_link", None) + if callable(create_invoice_link): + invoice_url = await create_invoice_link( + title=ctx.description, + description=ctx.description, + payload=payload, + # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "open_invoice", + "payment_url": invoice_url, + "payment_id": payment.payment_id, + } + ) + + await bot.send_invoice( + chat_id=ctx.user_id, + title=ctx.description, + description=ctx.description, + payload=payload, + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "invoice_sent", + "payment_id": payment.payment_id, + } + ) + except Exception: + await ctx.session.rollback() + logging.exception("Stars WebApp payment failed") + return payment_failed("Failed to create invoice") + + +SPEC = PaymentProviderSpec( + id="stars", + provider_key="telegram_stars", + label="Telegram Stars", + webapp_label="Telegram Stars", + webapp_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"}, + webapp_icon="Sparkles", + telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"}, + pending_status="pending_stars", + enabled=lambda settings: settings.STARS_ENABLED, + service_key="stars_service", + button_text_key="pay_with_stars_button", + callback_prefix="pay_stars", + router=router, + create_service=create_service, + create_webapp_payment=create_webapp_payment, + requires_configured_service=False, + price_source="stars", + emoji="⭐", + telegram_emoji="⭐", +) diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py new file mode 100644 index 0000000..dde4963 --- /dev/null +++ b/backend/bot/payment_providers/wata.py @@ -0,0 +1,450 @@ +import base64 +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple + +from aiogram import Bot, F, Router, types +from aiohttp import web +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from bot.utils.request_security import ip_in_allowlist, request_client_ip +from config.settings import Settings +from db.dal import payment_dal + +from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + decimal_amounts_equal, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + lookup_payment_by_order_or_provider_id, + make_translator, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + notify_user_payment_failed, + parse_payment_callback, + payment_failed, + payment_unavailable, + post_json_request, + render_link_or_fail, +) + +router = Router(name="user_subscription_payments_wata_router") +_LOG = "wata" + + +class WataService(HttpClientMixin): + 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._init_http_client(total_timeout=20) + 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.") + + 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(format_decimal_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"), + } + return await post_json_request( + session, + f"{self.base_url}/links", + body=body, + headers=self._auth_headers(), + log_prefix="Wata create_payment_link", + ) + + 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") + + async with self.async_session_factory() as session: + payment = await lookup_payment_by_order_or_provider_id( + session, + order_id_raw=order_id_raw, + provider_payment_id=transaction_id or None, + ) + 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") + + resolved_transaction_id = transaction_id or str(payment.payment_id) + + if status == "paid": + if amount_raw is not None: + try: + if not decimal_amounts_equal(amount_raw, payment.amount): + logging.warning( + "Wata webhook: amount mismatch for payment %s " + "(expected %s, got %s)", + payment.payment_id, + format_decimal_amount(payment.amount), + format_decimal_amount(amount_raw), + ) + 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, + resolved_transaction_id, + "succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Wata webhook: failed to mark payment %s as succeeded.", + resolved_transaction_id, + ) + return web.Response(status=500, text="processing_error") + + 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" + ) + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=str(currency), + sale_mode=sale_mode, + months=payment_units, + traffic_amount=float(payment_units), + provider_subscription="wata", + provider_notification="wata", + db_user=payment.user, + log_prefix="Wata webhook", + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") + + if status == "declined": + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_transaction_id, + "failed", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Wata webhook: failed to mark payment %s as failed.", + resolved_transaction_id, + ) + return web.Response(status=500, text="processing_error") + await notify_user_payment_failed( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + payment=payment, + ) + return web.Response(text="ok") + + logging.warning( + "Wata webhook: unhandled status '%s' for transaction %s", + status, + transaction_id, + ) + return web.Response(status=202, text="status_ignored") + + +@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") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not wata_service or not wata_service.configured: + logging.error("Wata service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_wata data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + + currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_wata", + description=payment_description, + months=parts.months, + provider="wata", + sale_mode=parts.sale_mode, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Wata: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await wata_service.create_payment_link( + payment_db_id=payment_record.payment_id, + amount=parts.price, + currency=currency_code, + description=payment_description, + ) + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=first_value(response_data, "url"), + provider_payment_id=first_value(response_data, "id"), + log_prefix=_LOG, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings: Settings = ctx.request.app["settings"] + service: WataService = ctx.request.app["wata_service"] + if not service or not service.configured: + return payment_unavailable() + + currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=currency, + status="pending_wata", + provider="wata", + ) + success, response_data = await service.create_payment_link( + payment_db_id=payment.payment_id, + amount=ctx.price, + currency=currency, + description=ctx.description, + ) + except Exception: + await ctx.session.rollback() + logging.exception("Wata WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(response_data, "url") if success else None, + provider_payment_id=first_value(response_data, "id"), + log_prefix="Wata", + ) + + +async def wata_webhook_route(request: web.Request) -> web.Response: + service: WataService = request.app["wata_service"] + return await service.webhook_route(request) + + +def create_service(ctx: ServiceFactoryContext) -> WataService: + return WataService( + bot=ctx.bot, + settings=ctx.settings, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + default_return_url=ctx.bot_username_for_default_return, + ) + + +SPEC = PaymentProviderSpec( + id="wata", + provider_key="wata", + label="Wata", + webapp_label="Wata", + webapp_labels={"ru": "Wata", "en": "Wata"}, + webapp_icon="WalletCards", + telegram_labels={"ru": "Wata", "en": "Wata"}, + telegram_emoji="💳", + pending_status="pending_wata", + enabled=lambda settings: settings.WATA_ENABLED, + service_key="wata_service", + button_text_key="pay_with_wata_button", + callback_prefix="pay_wata", + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.wata_webhook_path, + webhook_route=wata_webhook_route, + create_webapp_payment=create_webapp_payment, +) diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py new file mode 100644 index 0000000..ceb7116 --- /dev/null +++ b/backend/bot/payment_providers/yookassa.py @@ -0,0 +1,2510 @@ +import asyncio +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from aiogram import Bot, F, Router, types +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import sessionmaker +from yookassa import Configuration +from yookassa import Payment as YooKassaPayment +from yookassa.domain.common.confirmation_type import ConfirmationType +from yookassa.domain.notification import WebhookNotification +from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder + +from bot.infra.webhook_queue import enqueue_webhook_event +from bot.keyboards.inline.user_keyboards import ( + get_back_to_main_menu_markup, + get_bind_url_keyboard, + get_payment_method_delete_confirm_keyboard, + get_payment_method_details_keyboard, + get_payment_methods_list_keyboard, + get_payment_url_keyboard, + get_yk_autopay_choice_keyboard, + get_yk_saved_cards_keyboard, +) +from bot.middlewares.i18n import JsonI18n +from bot.services.lknpd_service import LknpdService +from bot.services.panel_api_service import PanelApiService +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 config.settings import Settings +from db.dal import payment_dal, user_billing_dal, user_dal +from db.models import Payment + +from .base import ( + PaymentProviderSpec, + ServiceFactoryContext, + WebAppPaymentContext, +) +from .shared import ( + SuccessMessage, + build_success_message, + create_webapp_payment_record, + format_human_units, + format_number_for_payload, + is_traffic_sale_base, + make_translator, + mark_payment_failed_creation, + notify_admins_payment_received, + payment_failed, + payment_link_response, + payment_record_amounts, + payment_unavailable, + resolve_inviter_name, + send_success_message_to_user, +) +from .shared import ( + sale_mode_base as _sale_mode_base, +) + + +class YooKassaService: + def __init__( + self, + shop_id: Optional[str], + secret_key: Optional[str], + configured_return_url: Optional[str], + bot_username_for_default_return: Optional[str] = None, + settings_obj: Optional[Settings] = None, + ): + + self.settings = settings_obj + + if self.settings and not self.settings.YOOKASSA_ENABLED: + logging.warning( + "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 + ) + self.configured = False + elif not shop_id or not secret_key: + logging.warning( + "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " + "Payment functionality will be DISABLED." + ) + self.configured = False + else: + try: + Configuration.configure(shop_id, secret_key) + self.configured = True + logging.info(f"YooKassa SDK configured for shop_id: {shop_id[:5]}...") + except Exception: + logging.exception("Failed to configure YooKassa SDK.") + self.configured = False + + if configured_return_url: + self.return_url = configured_return_url + elif bot_username_for_default_return: + self.return_url = f"https://t.me/{bot_username_for_default_return}" + logging.info( + f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501 + ) + else: + self.return_url = "https://example.com/payment_error_no_return_url_configured" + logging.warning( + f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. " + f"Using placeholder: {self.return_url}. Payments may not complete correctly." + ) + logging.info(f"YooKassa Service effective return_url for payments: {self.return_url}") + + async def create_payment( + self, + amount: float, + currency: str, + description: str, + metadata: Dict[str, Any], + receipt_email: Optional[str] = None, + receipt_phone: Optional[str] = None, + save_payment_method: bool = False, + payment_method_id: Optional[str] = None, + capture: bool = True, + bind_only: bool = False, + ) -> Optional[Dict[str, Any]]: + if not self.configured: + logging.error("YooKassa is not configured. Cannot create payment.") + return None + + if not self.settings: + logging.error( + "YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501 + ) + return { + "error": True, + "internal_message": "Service settings (Settings object) not initialized.", + } + + customer_contact_for_receipt = {} + if receipt_email: + customer_contact_for_receipt["email"] = receipt_email + elif receipt_phone: + customer_contact_for_receipt["phone"] = receipt_phone + elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: + customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + else: + logging.error( + "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501 + ) + return { + "error": True, + "internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501 + } + + try: + builder = PaymentRequestBuilder() + builder.set_amount({"value": str(round(amount, 2)), "currency": currency.upper()}) + # For binding cards only, do not capture and set minimal amount + if bind_only: + capture = False + amount = max(amount, 1.00) + builder.set_capture(capture) + if not payment_method_id: + # Saved payment_method_id charges must omit confirmation per YooKassa API + builder.set_confirmation( + {"type": ConfirmationType.REDIRECT, "return_url": self.return_url} + ) + builder.set_description(description) + builder.set_metadata(metadata) + if save_payment_method: + # Ask YooKassa to save method for off-session charges + builder.set_save_payment_method(True) + if payment_method_id: + # Use a previously saved payment method for merchant-initiated payments + builder.set_payment_method_id(payment_method_id) + + receipt_items_list: List[Dict[str, Any]] = [ + { + "description": description[:128], + "quantity": "1.00", + "amount": {"value": str(round(amount, 2)), "currency": currency.upper()}, + "vat_code": str(self.settings.YOOKASSA_VAT_CODE), + "payment_mode": getattr( + self.settings, + "yk_receipt_payment_mode", + self.settings.YOOKASSA_PAYMENT_MODE, + ), + "payment_subject": getattr( + self.settings, + "yk_receipt_payment_subject", + self.settings.YOOKASSA_PAYMENT_SUBJECT, + ), + } + ] + + receipt_data_dict: Dict[str, Any] = { + "customer": customer_contact_for_receipt, + "items": receipt_items_list, + } + + builder.set_receipt(receipt_data_dict) + + idempotence_key = str(uuid.uuid4()) + payment_request = builder.build() + + logging.info( + f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). " + f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}" + ) + + response = await asyncio.to_thread( + YooKassaPayment.create, + payment_request, + idempotence_key, + ) + + logging.info( + f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501 + ) + + return { + "id": response.id, + "confirmation_url": response.confirmation.confirmation_url + if response.confirmation + else None, + "status": response.status, + "metadata": response.metadata, + "amount_value": float(response.amount.value), + "amount_currency": response.amount.currency, + "idempotence_key_used": idempotence_key, + "paid": response.paid, + "refundable": response.refundable, + "created_at": response.created_at.isoformat() + if hasattr(response.created_at, "isoformat") + else str(response.created_at), + "description_from_yk": response.description, + "test_mode": response.test if hasattr(response, "test") else None, + "payment_method": getattr(response, "payment_method", None), + } + except Exception: + logging.exception("YooKassa payment creation failed.") + return None + + async def get_payment_info(self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]: + if not self.configured: + logging.error("YooKassa is not configured. Cannot get payment info.") + return None + try: + logging.info(f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}") + + payment_info_yk = await asyncio.to_thread( + YooKassaPayment.find_one, + payment_id_in_yookassa, + ) + + if payment_info_yk: + logging.info( + f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501 + ) + pm = getattr(payment_info_yk, "payment_method", None) + pm_payload: Dict[str, Any] = {} + if pm: + # Collect common fields, including id and hints for last4 + pm_id = getattr(pm, "id", None) + pm_type = getattr(pm, "type", None) + pm_title = getattr(pm, "title", None) + account_number = getattr(pm, "account_number", None) or getattr( + pm, "account", None + ) + card_obj = getattr(pm, "card", None) + last4_val = None + if card_obj and hasattr(card_obj, "last4"): + last4_val = getattr(card_obj, "last4") + elif isinstance(account_number, str) and len(account_number) >= 4: + last4_val = account_number[-4:] + pm_payload = { + "id": pm_id, + "type": pm_type, + "title": pm_title, + "card_last4": last4_val, + } + return { + "id": payment_info_yk.id, + "status": payment_info_yk.status, + "paid": payment_info_yk.paid, + "amount_value": float(payment_info_yk.amount.value), + "amount_currency": payment_info_yk.amount.currency, + "metadata": payment_info_yk.metadata, + "description": payment_info_yk.description, + "refundable": payment_info_yk.refundable, + "created_at": payment_info_yk.created_at.isoformat() + if hasattr(payment_info_yk.created_at, "isoformat") + else str(payment_info_yk.created_at), + "captured_at": payment_info_yk.captured_at.isoformat() + if getattr(payment_info_yk, "captured_at", None) + and hasattr(payment_info_yk.captured_at, "isoformat") + else None, + "payment_method": pm_payload, + "test_mode": getattr(payment_info_yk, "test", None), + } + else: + logging.warning( + f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}" + ) + return None + except Exception: + logging.exception("YooKassa get payment info for %s failed.", payment_id_in_yookassa) + return None + + async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: + if not self.configured: + logging.error("YooKassa is not configured. Cannot cancel payment.") + return False + try: + await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa) + logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") + return True + except Exception: + logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa) + return False + + +payment_processing_lock = asyncio.Lock() + +YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded" +YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled" +YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture" +YOOKASSA_WEBHOOK_ALLOWED_IPS = [ + "185.71.76.0/27", + "185.71.77.0/27", + "77.75.153.0/25", + "77.75.156.11", + "77.75.156.35", + "77.75.154.128/25", + "2a02:5180::/32", +] + + +async def process_successful_payment( + session: AsyncSession, + bot: Bot, + payment_info_from_webhook: dict, + i18n: JsonI18n, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + referral_service: ReferralService, + lknpd_service: Optional[LknpdService] = None, +): + metadata = payment_info_from_webhook.get("metadata", {}) + user_id_str = metadata.get("user_id") + subscription_months_str = metadata.get("subscription_months") + traffic_gb_str = metadata.get("traffic_gb") + sale_mode = metadata.get("sale_mode") or ( + "traffic" if settings.traffic_sale_mode else "subscription" + ) + sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0] + promo_code_id_str = metadata.get("promo_code_id") + payment_db_id_str = metadata.get("payment_db_id") + auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id") + + # For auto-renew payments, payment_db_id may be absent. In that case, + # we will create/ensure a payment record idempotently using provider payment id. + if ( + not user_id_str + or (not subscription_months_str and not traffic_gb_str) + or (not payment_db_id_str and not auto_renew_subscription_id_str) + ): + logging.error( + f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501 + ) + return + + db_user = None + try: + user_id = int(user_id_str) + subscription_months = float(subscription_months_str or 0) + traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months + payment_db_id = ( + int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None + ) + is_auto_renew = bool( + auto_renew_subscription_id_str + and not payment_db_id + and sale_mode_base == "subscription" + ) + promo_code_id = ( + int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None + ) + + amount_data = payment_info_from_webhook.get("amount", {}) + months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 + payment_value = float(amount_data.get("value", 0.0)) + yk_payment_id_from_hook = payment_info_from_webhook.get("id") + + payment_record = None + # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists + if payment_db_id is None and auto_renew_subscription_id_str: + try: + if not yk_payment_id_from_hook: + logging.error( + "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501 + ) + return + from db.dal import payment_dal as _payment_dal + + payment_record = await _payment_dal.get_payment_by_provider_payment_id( + session, yk_payment_id_from_hook + ) + if not payment_record: + payment_record = await _payment_dal.ensure_payment_with_provider_id( + session, + user_id=user_id, + amount=payment_value, + currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL), + months=months_for_record or 1, + description=payment_info_from_webhook.get("description") + or f"Auto-renewal for {months_for_record or subscription_months} months", + provider="yookassa", + provider_payment_id=yk_payment_id_from_hook, + ) + payment_db_id = payment_record.payment_id + except Exception as e_ensure: + logging.error( + f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501 + exc_info=True, + ) + return + elif payment_db_id is not None: + payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment_record: + logging.error( + f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}." + ) + return + + if payment_record and payment_record.status == "succeeded": + logging.info( + f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501 + ) + return + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error( + f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501 + ) + + await payment_dal.update_payment_status_by_db_id( + session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id") + ) + + return + + except (TypeError, ValueError) as e: + logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}") + + if payment_db_id_str and payment_db_id_str.isdigit(): + try: + await payment_dal.update_payment_status_by_db_id( + session, + int(payment_db_id_str), + "failed_metadata_error", + payment_info_from_webhook.get("id"), + ) + except Exception as e_upd: + logging.error(f"Failed to update payment status after metadata error: {e_upd}") + return + + try: + yk_payment_id_from_hook = payment_info_from_webhook.get("id") + payment_before_update = None + if payment_db_id is not None: + payment_before_update = await payment_dal.get_payment_by_db_id( + session, + payment_db_id, + ) + should_send_lknpd_receipt = bool( + lknpd_service + and lknpd_service.configured + and payment_info_from_webhook.get("paid") is True + and payment_info_from_webhook.get("status") == "succeeded" + and payment_before_update + and payment_before_update.status != "succeeded" + ) + # Try to capture and save payment method for future charges if available + try: + payment_method = payment_info_from_webhook.get("payment_method") + if ( + settings.yookassa_autopayments_active + and isinstance(payment_method, dict) + and payment_method.get("saved", False) + ): + pm_id = payment_method.get("id") + pm_type = payment_method.get("type") + title = payment_method.get("title") + card = payment_method.get("card") or {} + account_number = payment_method.get("account_number") or payment_method.get( + "account" + ) + display_network = None + display_last4 = None + # Build generic display for various instrument types + if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: + display_network = card.get("card_type") or title or "Card" + display_last4 = card.get("last4") + elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: + # Normalize wallet display name to avoid leaking full account from title + display_network = "YooMoney" + if isinstance(account_number, str) and len(account_number) >= 4: + display_last4 = account_number[-4:] + else: + display_last4 = None + else: + # Wallets, SBP, etc. — use provided title/type; no last4 + display_network = title or (pm_type.upper() if pm_type else "Payment method") + display_last4 = None + + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm_id, + card_last4=display_last4, + card_network=display_network, + ) + try: + await user_billing_dal.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=pm_id, + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=True, + ) + except Exception: + logging.exception("Failed to persist multi-card YooKassa method from webhook") + except Exception: + logging.exception("Failed to persist YooKassa payment method from webhook") + months_for_activation = ( + int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb) + ) + activation_details = await subscription_service.activate_subscription( + session, + user_id, + months_for_activation, + payment_value, + payment_db_id, + promo_code_id_from_payment=promo_code_id, + provider="yookassa", + sale_mode=sale_mode, + traffic_gb=traffic_amount_gb + if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} + else None, + ) + + if not activation_details or not activation_details.get("end_date"): + logging.error( + f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501 + ) + raise Exception(f"Subscription Error: Failed to activate for user {user_id}") + + updated_payment_record = await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=payment_db_id, + new_status=payment_info_from_webhook.get("status", "succeeded"), + yk_payment_id=yk_payment_id_from_hook, + ) + if not updated_payment_record: + logging.error( + f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501 + ) + raise Exception(f"DB Error: Could not update payment record {payment_db_id}") + + base_subscription_end_date = activation_details["end_date"] + final_end_date_for_user = base_subscription_end_date + applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0) + + referral_bonus_info = None + if sale_mode_base == "subscription": + referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( + session, + user_id, + months_for_activation or int(subscription_months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) + applied_referee_bonus_days_from_referral: Optional[int] = None + if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"): + final_end_date_for_user = referral_bonus_info["referee_new_end_date"] + applied_referee_bonus_days_from_referral = referral_bonus_info.get( + "referee_bonus_applied_days" + ) + + # Use user's DB language for all user-facing messages + user_lang = ( + db_user.language_code + if db_user and db_user.language_code + else settings.DEFAULT_LANGUAGE + ) + translator = make_translator(i18n, user_lang) + _ = translator + + traffic_label = format_human_units(traffic_amount_gb) + if should_send_lknpd_receipt: + receipt_item_name = payment_info_from_webhook.get("description") + if not receipt_item_name: + if is_traffic_sale_base(sale_mode_base): + receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) + else: + receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format( + months=int(subscription_months) + ) + try: + await lknpd_service.create_income_receipt( + item_name=receipt_item_name, + amount=payment_value, + quantity=1.0, + operation_time=datetime.now(timezone.utc), + ) + except Exception: + logging.exception( + "Failed to send LKNPD receipt for payment %s", + yk_payment_id_from_hook, + ) + config_link_display, connect_button_url = await prepare_config_links( + settings, activation_details.get("subscription_url") if activation_details else None + ) + config_link_text = config_link_display or _("config_link_not_available") + # Auto-renew charges show a concise message and skip the connect keyboard, so + # they bypass the shared success-message builder. + if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user: + details_message = _( + "yookassa_auto_renewal", + months=int(subscription_months), + end_date=final_end_date_for_user.strftime("%Y-%m-%d"), + ) + include_keyboard = False + elif not final_end_date_for_user and not is_traffic_sale_base(sale_mode_base): + logging.error( + f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501 + ) + details_message = _("payment_successful_error_details") + include_keyboard = True + else: + inviter_name = None + if applied_referee_bonus_days_from_referral and final_end_date_for_user: + inviter_name = await resolve_inviter_name(session, translator, db_user) + details_message = build_success_message( + SuccessMessage( + translator=translator, + sale_mode=sale_mode, + months=( + traffic_label + if is_traffic_sale_base(sale_mode_base) + else int(subscription_months) + ), + base_end_date=base_subscription_end_date, + final_end_date=final_end_date_for_user, + config_link_text=config_link_text, + applied_referee_bonus_days=applied_referee_bonus_days_from_referral or 0, + applied_promo_bonus_days=applied_promo_bonus_days, + inviter_name=inviter_name, + fallback_date_text="—", + ) + ) + include_keyboard = True + await send_success_message_to_user( + bot=bot, + user_id=user_id, + text=details_message, + language=user_lang, + i18n=i18n, + settings=settings, + config_link_display=config_link_display, + connect_button_url=connect_button_url, + include_keyboard=include_keyboard, + log_prefix="YooKassa webhook", + ) + + tariff_for_log = None + for source in (payment_before_update, updated_payment_record, payment_record): + if source is not None and getattr(source, "tariff_key", None): + tariff_for_log = source.tariff_key + break + user_for_notify = db_user or await user_dal.get_user_by_id(session, user_id) + await notify_admins_payment_received( + bot=bot, + settings=settings, + i18n=i18n, + user_id=user_id, + amount=payment_value, + currency=settings.DEFAULT_CURRENCY_SYMBOL, + months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0, + traffic_gb_for_admin=( + traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None + ), + payment_provider="yookassa", + username=user_for_notify.username if user_for_notify else None, + traffic_is_premium=sale_mode_base == "premium_topup", + tariff_key=tariff_for_log, + log_prefix="YooKassa webhook", + ) + + except Exception as e_process: + logging.error( + f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501 + exc_info=True, + ) + + raise + + +async def process_cancelled_payment( + session: AsyncSession, + bot: Bot, + payment_info_from_webhook: dict, + i18n: JsonI18n, + settings: Settings, +): + + metadata = payment_info_from_webhook.get("metadata", {}) + user_id_str = metadata.get("user_id") + payment_db_id_str = metadata.get("payment_db_id") + + if not user_id_str or not payment_db_id_str: + logging.warning( + f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}" + ) + return + try: + user_id = int(user_id_str) + payment_db_id = int(payment_db_id_str) + except ValueError: + logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}") + return + + try: + updated_payment = await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=payment_db_id, + new_status=payment_info_from_webhook.get("status", "canceled"), + yk_payment_id=payment_info_from_webhook.get("id"), + ) + + if updated_payment: + logging.info( + f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501 + ) + else: + logging.warning( + f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501 + ) + + db_user = await user_dal.get_user_by_id(session, user_id) + user_lang = settings.DEFAULT_LANGUAGE + if db_user and db_user.language_code: + user_lang = db_user.language_code + + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + await bot.send_message(user_id, _("payment_failed")) + + except Exception as e_process_cancel: + logging.error( + f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501 + exc_info=True, + ) + raise + + +async def yookassa_webhook_route(request: web.Request): + + try: + bot: Bot = request.app["bot"] + i18n_instance: JsonI18n = request.app["i18n"] + settings: Settings = request.app["settings"] + panel_service: PanelApiService = request.app["panel_service"] + subscription_service: SubscriptionService = request.app["subscription_service"] + referral_service: ReferralService = request.app["referral_service"] + lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service") + async_session_factory: sessionmaker = request.app["async_session_factory"] + except KeyError: + logging.exception("KeyError accessing app context in yookassa_webhook_route.") + return web.Response(status=500, text="Internal Server Error: Missing app context component") + + client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies) + if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS): + logging.warning("YooKassa webhook denied from unauthorized IP source.") + return web.Response(status=403) + + try: + event_json = await request.json() + + notification_object = WebhookNotification(event_json) + payment_data_from_notification = notification_object.object + + logging.info( + f"YooKassa Webhook Parsed: Event='{notification_object.event}', " + f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501 + ) + + if ( + not payment_data_from_notification + or not hasattr(payment_data_from_notification, "metadata") + or payment_data_from_notification.metadata is None + ): + logging.error( + f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501 + ) + return web.Response(status=200, text="ok_error_no_metadata") + + # Safely extract payment_method details (SDK objects may not have to_dict) + pm_obj = getattr(payment_data_from_notification, "payment_method", None) + pm_dict = None + if pm_obj is not None: + try: + card_obj = getattr(pm_obj, "card", None) + pm_dict = { + "id": getattr(pm_obj, "id", None), + "type": getattr(pm_obj, "type", None), + "saved": bool(getattr(pm_obj, "saved", False)), + "title": getattr(pm_obj, "title", None), + "account_number": ( + getattr(pm_obj, "account_number", None) + if hasattr(pm_obj, "account_number") + else ( + getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None + ) + ), + "card": ( + { + "first6": getattr(card_obj, "first6", None), + "last4": getattr(card_obj, "last4", None), + "expiry_month": getattr(card_obj, "expiry_month", None), + "expiry_year": getattr(card_obj, "expiry_year", None), + "card_type": getattr(card_obj, "card_type", None), + } + if card_obj is not None + else None + ), + } + except Exception: + logging.exception("Failed to serialize YooKassa payment_method from webhook") + pm_dict = None + + payment_dict_for_processing = { + "id": str(payment_data_from_notification.id), + "status": str(payment_data_from_notification.status), + "paid": bool(payment_data_from_notification.paid), + "amount": { + "value": str(payment_data_from_notification.amount.value), + "currency": str(payment_data_from_notification.amount.currency), + } + if payment_data_from_notification.amount + else {}, + "metadata": dict(payment_data_from_notification.metadata), + "description": str(payment_data_from_notification.description) + if payment_data_from_notification.description + else None, + "payment_method": pm_dict, + } + + if notification_object.event in { + YOOKASSA_EVENT_PAYMENT_SUCCEEDED, + YOOKASSA_EVENT_PAYMENT_CANCELED, + }: + queued = await enqueue_webhook_event( + settings, + "yookassa", + { + "event": notification_object.event, + "payment": payment_dict_for_processing, + }, + event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}", + ) + if queued: + return web.Response(status=200, text="queued") + + async with payment_processing_lock: + async with async_session_factory() as session: + try: + if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: + if ( + payment_dict_for_processing.get("paid") + and payment_dict_for_processing.get("status") == "succeeded" + ): + await process_successful_payment( + session, + bot, + payment_dict_for_processing, + i18n_instance, + settings, + panel_service, + subscription_service, + referral_service, + lknpd_service, + ) + await session.commit() + else: + logging.warning( + f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501 + f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501 + f"paid='{payment_dict_for_processing.get('paid')}'" + ) + elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: + await process_cancelled_payment( + session, bot, payment_dict_for_processing, i18n_instance, settings + ) + await session.commit() + elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: + # Bind-only flow: save method and cancel auth if metadata has bind_only + metadata = payment_dict_for_processing.get("metadata", {}) or {} + if ( + settings.yookassa_autopayments_active + and metadata.get("bind_only") == "1" + ): + try: + user_id_str = metadata.get("user_id") + if user_id_str and user_id_str.isdigit(): + user_id = int(user_id_str) + payment_method = payment_dict_for_processing.get( + "payment_method" + ) + if isinstance(payment_method, dict) and payment_method.get( + "id" + ): + pm_type = payment_method.get("type") + title = payment_method.get("title") + card = payment_method.get("card") or {} + account_number = payment_method.get( + "account_number" + ) or payment_method.get("account") + display_network = None + display_last4 = None + if (pm_type or "").lower() in { + "bank_card", + "bank-card", + "card", + }: + display_network = ( + card.get("card_type") or title or "Card" + ) + display_last4 = card.get("last4") + elif (pm_type or "").lower() in { + "yoo_money", + "yoomoney", + "yoo-money", + "wallet", + }: + # Normalize wallet display name to avoid leaking full account from title # noqa: E501 + display_network = "YooMoney" + if ( + isinstance(account_number, str) + and len(account_number) >= 4 + ): + display_last4 = account_number[-4:] + else: + display_last4 = None + else: + display_network = title or ( + pm_type.upper() if pm_type else "Payment method" + ) + display_last4 = None + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=payment_method.get("id"), + card_last4=display_last4, + card_network=display_network, + ) + await session.commit() + # Save multi-card entry and mark default if first + try: + from db.dal import user_billing_dal as ub + + await ub.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=payment_method.get("id"), + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=True, + ) + await session.commit() + except Exception: + await session.rollback() + # Notify user about successful binding with Back button + try: + # Use user's DB language for bind success notification + i18n_lang = settings.DEFAULT_LANGUAGE + from db.dal import user_dal + + db_user = await user_dal.get_user_by_id( + session, user_id + ) + if db_user and db_user.language_code: + i18n_lang = db_user.language_code + _ = lambda key, **kwargs: i18n_instance.gettext( + i18n_lang, key, **kwargs + ) + from bot.keyboards.inline.user_keyboards import ( + get_back_to_payment_methods_keyboard, + ) + + await bot.send_message( + chat_id=user_id, + text=_("payment_method_bound_success"), + reply_markup=get_back_to_payment_methods_keyboard( + i18n_lang, i18n_instance + ), + ) + except Exception: + pass + # Attempt to cancel the authorization to avoid charge hold + try: + yk: YooKassaService = request.app.get( + "yookassa_service" + ) + if yk: + await yk.cancel_payment( + payment_dict_for_processing.get("id") + ) + except Exception: + logging.exception( + "Failed to cancel bind-only payment auth" + ) + except Exception: + logging.exception( + "Failed to handle bind-only waiting_for_capture webhook" + ) + except Exception: + await session.rollback() + logging.exception( + "Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501 + notification_object.event, + payment_dict_for_processing.get("id"), + ) + return web.Response(status=500, text="internal_processing_error") + + return web.Response(status=200, text="ok") + + except json.JSONDecodeError: + logging.error("YooKassa Webhook: Invalid JSON received.") + return web.Response(status=400, text="bad_request_invalid_json") + except Exception: + logging.exception("YooKassa Webhook general processing error.") + return web.Response(status=500, text="internal_error") + + +router = Router(name="user_subscription_payments_yookassa_router") + + +def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + + +def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: + try: + parts = payload.split(":") + value = float(parts[0]) + price = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" + return value, price, sale_mode + except (ValueError, IndexError): + return None + + +def _format_saved_payment_method_title( + get_text, network: Optional[str], last4: Optional[str], is_default: bool +) -> str: + def _is_yoomoney_network(name: Optional[str]) -> bool: + s = (name or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + if _is_yoomoney_network(network): + inferred_last4 = last4 or (_extract_last4(network or "") or "****") + title = get_text("payment_method_wallet_title", last4=inferred_last4) + elif last4: + network_name = network or get_text("payment_network_card") + title = get_text("payment_method_card_title", network=network_name, last4=last4) + else: + network_name = network or get_text("payment_network_generic") + title = get_text("payment_method_generic_title", network=network_name) + return f"⭐ {title}" if is_default else title + + +async def _initiate_yk_payment( + callback: types.CallbackQuery, + *, + settings: Settings, + session: AsyncSession, + yookassa_service: YooKassaService, + i18n: Optional[JsonI18n], + current_lang: str, + get_text, + user_id: int, + months: int, + price_rub: float, + currency_code_for_yk: str, + save_payment_method: bool, + back_callback: str, + payment_method_id: Optional[str] = None, + selected_method_internal_id: Optional[int] = None, + sale_mode: str = "subscription", +) -> bool: + """Create payment record and initiate YooKassa payment (new card or saved card).""" + if not callback.message: + return False + + sale_base = _sale_mode_base(sale_mode) + payment_description = ( + get_text("payment_description_traffic", traffic_gb=_format_value(months)) + 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)) + ) + ) + payment_record_data = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code_for_yk, + "status": "pending_yookassa", + "description": payment_description, + "subscription_duration_months": int(months) if sale_base == "subscription" else None, + "sale_mode": sale_base, + "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, + } + + db_payment_record = None + try: + db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) + await session.commit() + logging.info( + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501 + ) + except Exception as e_db_payment: + await session.rollback() + logging.error( + f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + if not db_payment_record: + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + yookassa_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(db_payment_record.payment_id), + "sale_mode": sale_mode, + } + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: + yookassa_metadata["traffic_gb"] = str(months) + if payment_method_id: + yookassa_metadata["used_saved_payment_method_id"] = payment_method_id + + receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + + payment_response_yk = await yookassa_service.create_payment( + amount=price_rub, + currency=currency_code_for_yk, + description=payment_description, + metadata=yookassa_metadata, + receipt_email=receipt_email_for_yk, + save_payment_method=save_payment_method, + payment_method_id=payment_method_id, + ) + + if payment_response_yk and payment_response_yk.get("confirmation_url"): + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get("id"): + pm_type = pm.get("type") + title = pm.get("title") + card = pm.get("card") or {} + account_number = pm.get("account_number") or pm.get("account") + if isinstance(card, dict) and (pm_type or "").lower() in { + "bank_card", + "bank-card", + "card", + }: + display_network = card.get("card_type") or title or "Card" + display_last4 = card.get("last4") + elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: + display_network = "YooMoney" + display_last4 = ( + account_number[-4:] + if isinstance(account_number, str) and len(account_number) >= 4 + else None + ) + else: + display_network = title or (pm_type.upper() if pm_type else "Payment method") + display_last4 = None + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm["id"], + card_last4=display_last4, + card_network=display_network, + ) + try: + await user_billing_dal.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=pm["id"], + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=save_payment_method, + ) + except Exception: + pass + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to save YooKassa payment method preliminarily") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=payment_response_yk.get("status", "pending"), + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception( + "Failed to set default payment method after initiating payment" + ) + await session.commit() + except Exception as e_db_update_ykid: + await session.rollback() + logging.error( + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501 + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) + except Exception: + pass + return False + + 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=_format_value(months), + ), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.") + 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=_format_value(months), + ), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + return True + + if payment_response_yk and payment_method_id: + status_to_store = payment_response_yk.get("status", "pending") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=status_to_store, + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception( + "Failed to set default payment method after saved-card payment start" + ) + await session.commit() + except Exception as e_db_update_saved: + await session.rollback() + logging.error( + f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501 + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + message_text = get_text("yookassa_autopay_charge_initiated") + try: + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception as e_edit: + logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") + try: + await callback.message.answer( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception: + pass + return True + + try: + await payment_dal.update_payment_status_by_db_id( + session, db_payment_record.payment_id, "failed_creation" + ) + await session.commit() + except Exception as e_db_fail_create: + await session.rollback() + logging.error( + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501 + exc_info=True, + ) + logging.error( + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501 + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + +@router.callback_query(F.data.startswith("pay_yk:")) +async def pay_yk_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + yookassa_service: YooKassaService, + 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 yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service is not configured or unavailable.") + target_msg_edit = callback.message + await target_msg_edit.edit_text(get_text("payment_service_unavailable")) + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_offer_payload(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub, sale_mode = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool( + settings.yookassa_autopayments_active + and _sale_mode_base(sale_mode) == "subscription" + and not settings.traffic_sale_mode + ) + autopay_require_binding = bool( + getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True) + ) + saved_methods: List = [] + if autopay_enabled: + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if autopay_enabled and saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + sale_mode=sale_mode, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") + try: + await callback.message.answer( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + sale_mode=sale_mode, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_new:")) +async def pay_yk_new_card_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + yookassa_service: YooKassaService, + 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 yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_new.") + 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) + except ValueError: + logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_offer_payload(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub, sale_mode = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool( + settings.yookassa_autopayments_active + and _sale_mode_base(sale_mode) == "subscription" + and not settings.traffic_sale_mode + ) + autopay_require_binding = bool( + getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True) + ) + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_saved_list:")) +async def pay_yk_saved_list_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + yookassa_service: YooKassaService, + 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 + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 2: + logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = float(parts[0]) + price_rub = float(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 0 + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except (ValueError, IndexError): + logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + autopay_enabled = bool( + settings.yookassa_autopayments_active + and _sale_mode_base(sale_mode) == "subscription" + and not settings.traffic_sale_mode + ) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if not saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + sale_mode=sale_mode, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display no-saved-card notice: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + sale_mode=sale_mode, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + cards: List[Tuple[str, str]] = [] + for method in saved_methods: + title = _format_saved_payment_method_title( + get_text, method.card_network, method.card_last4, method.is_default + ) + cards.append((str(method.method_id), title)) + + per_page = 5 + max_page = max(0, (len(cards) - 1) // per_page) + page = max(0, min(page, max_page)) + + try: + await callback.message.edit_text( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + sale_mode=sale_mode, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display saved card list: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + sale_mode=sale_mode, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_use_saved:")) +async def pay_yk_use_saved_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + yookassa_service: YooKassaService, + 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 yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_use_saved.") + 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) + except ValueError: + logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 3: + logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except (ValueError, IndexError): + logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + autopay_enabled = bool( + settings.yookassa_autopayments_active + and _sale_mode_base(sale_mode) == "subscription" + and not settings.traffic_sale_mode + ) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + method_identifier = parts[2] + user_id = callback.from_user.id + + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + selected_method = None + for method in saved_methods: + if method_identifier.isdigit(): + if method.method_id == int(method_identifier): + selected_method = method + break + if method.provider_payment_method_id == method_identifier: + selected_method = method + break + + if not selected_method: + logging.warning( + f"Selected payment method not found for user {user_id}: {method_identifier}" + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_code_for_yk = "RUB" + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=False, + back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", + payment_method_id=selected_method.provider_payment_method_id, + selected_method_internal_id=selected_method.method_id, + sale_mode=sale_mode, + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:manage") +async def payment_methods_manage( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + from db.dal.user_billing_dal import list_user_payment_methods + + get_text = _ + methods = await list_user_payment_methods(session, callback.from_user.id) + cards: List[tuple] = [] + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return get_text("payment_method_wallet_title", last4=l4) + return get_text("payment_method_wallet_title", last4="****") + if last4: + network_name = network or get_text("payment_network_card") + return get_text("payment_method_card_title", network=network_name, last4=last4) + network_name = network or get_text("payment_network_generic") + return get_text("payment_method_generic_title", network=network_name) + + for m in methods: + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + + text = get_text("payment_methods_title") + if not cards: + text += "\n\n" + get_text("payment_method_none") + + await callback.message.edit_text( + text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n) + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:bind") +async def payment_method_bind( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + yookassa_service: YooKassaService, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} + resp = await yookassa_service.create_payment( + amount=1.00, + currency="RUB", + description="Bind card", + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=True, + capture=False, + bind_only=True, + ) + if not resp or not resp.get("confirmation_url"): + await callback.answer(_("error_payment_gateway"), show_alert=True) + return + await callback.message.edit_text( + _("payment_methods_title"), + reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n), + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:delete_confirm")) +async def payment_method_delete_confirm( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + parts = callback.data.split(":", 2) + pm_id = parts[2] if len(parts) >= 3 else "" + await callback.message.edit_text( + _("payment_method_delete_confirm"), + reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n), + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:delete")) +async def payment_method_delete( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + parts = callback.data.split(":", 2) + pm_id_raw = parts[2] if len(parts) >= 3 else "" + deleted = False + + try: + from db.dal.user_billing_dal import ( + delete_user_payment_method, + delete_user_payment_method_by_provider_id, + list_user_payment_methods, + ) + + if pm_id_raw: + if pm_id_raw.isdigit(): + deleted = await delete_user_payment_method( + session, callback.from_user.id, int(pm_id_raw) + ) + else: + deleted = await delete_user_payment_method_by_provider_id( + session, callback.from_user.id, pm_id_raw + ) + try: + legacy_deleted = await user_billing_dal.delete_yk_payment_method( + session, callback.from_user.id + ) + deleted = deleted or legacy_deleted + except Exception: + pass + await session.commit() + + methods = await list_user_payment_methods(session, callback.from_user.id) + text = _("payment_methods_title") + cards = [] + for m in methods: + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic") + return _("payment_method_generic_title", network=network_name) + + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + if not cards: + text += "\n\n" + _("payment_method_none") + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") + await callback.message.edit_text( + f"{msg}\n\n{text}", + reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n), + ) + try: + await callback.answer() + except Exception: + pass + return + except Exception: + await session.rollback() + try: + await callback.answer(_("error_try_again"), show_alert=True) + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:view")) +async def payment_method_view( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + if not billing or not billing.yookassa_payment_method_id: + from db.dal.user_billing_dal import list_user_payment_methods + + methods = await list_user_payment_methods(session, callback.from_user.id) + if not methods: + await callback.answer(_("payment_method_none"), show_alert=True) + return + parts = callback.data.split(":", 2) + pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id) + sel = next( + ( + m + for m in methods + if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id + ), + methods[0], + ) + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic") + return _("payment_method_generic_title", network=network_name) + + title = _format_pm_title(sel.card_network, sel.card_last4) + added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else "—" + last_tx = "—" + try: + stmt = ( + select(Payment) + .where( + Payment.user_id == callback.from_user.id, + Payment.status == "succeeded", + Payment.provider == "yookassa", + ) + .order_by(Payment.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + lp = result.scalar_one_or_none() + if lp and lp.created_at: + last_tx = lp.created_at.strftime("%Y-%m-%d") + except Exception: + pass + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 + await callback.message.edit_text( + details, + reply_markup=get_payment_method_details_keyboard( + str(sel.method_id), current_lang, i18n + ), + ) + try: + await callback.answer() + except Exception: + pass + return + + added_at = ( + billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else "—" + ) + last_tx = "—" + try: + stmt = ( + select(Payment) + .where( + Payment.user_id == callback.from_user.id, + Payment.status == "succeeded", + Payment.provider == "yookassa", + ) + .order_by(Payment.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + last_payment = result.scalar_one_or_none() + if last_payment and last_payment.created_at: + last_tx = last_payment.created_at.strftime("%Y-%m-%d") + except Exception: + pass + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic") + return _("payment_method_generic_title", network=network_name) + + title = _format_pm_title(billing.card_network, billing.card_last4) + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 + await callback.message.edit_text( + details, + reply_markup=get_payment_method_details_keyboard( + billing.yookassa_payment_method_id, current_lang, i18n + ), + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:history")) +async def payment_method_history( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + yookassa_service: YooKassaService, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not settings.yookassa_autopayments_active: + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + from db.dal import payment_dal + + payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0) + user_payments = [p for p in payments if p.user_id == callback.from_user.id] + + selected_pm_provider_id: Optional[str] = None + pm_filter_requested: bool = False + try: + split_a, split_b, split_pm_id = callback.data.split(":", 2) + if split_pm_id: + pm_filter_requested = True + if split_pm_id.isdigit(): + from db.dal.user_billing_dal import list_user_payment_methods + + methods = await list_user_payment_methods(session, callback.from_user.id) + sel = next((m for m in methods if str(m.method_id) == split_pm_id), None) + if sel and sel.provider_payment_method_id: + selected_pm_provider_id = sel.provider_payment_method_id + else: + selected_pm_provider_id = split_pm_id + except Exception: + selected_pm_provider_id = None + pm_filter_requested = False + + if pm_filter_requested and not selected_pm_provider_id: + user_payments = [] + + if selected_pm_provider_id: + filtered: List[Payment] = [] + for p in user_payments: + if p.provider != "yookassa": + continue + if p.yookassa_payment_id and yookassa_service: + try: + info = await yookassa_service.get_payment_info(p.yookassa_payment_id) + pm = (info or {}).get("payment_method") or {} + if pm.get("id") == selected_pm_provider_id: + filtered.append(p) + continue + except Exception: + pass + user_payments = filtered + + if not user_payments: + from bot.keyboards.inline.user_keyboards import ( + get_back_to_payment_method_details_keyboard, + get_payment_methods_manage_keyboard, + ) + + back_pm_id = "" + try: + split_a, split_b, back_pm_id = callback.data.split(":", 2) + except Exception: + back_pm_id = "" + back_markup = ( + get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n) + if back_pm_id + else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + ) + await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) + return + + traffic_mode = getattr(settings, "traffic_sale_mode", False) + + def _format_item(p: Payment) -> str: + if traffic_mode: + units_val = p.subscription_duration_months or 0 + units_display = ( + str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" + ) + title = p.description or _("traffic_purchase_title", traffic_gb=units_display) + else: + title = p.description or _( + "subscription_purchase_title", months=p.subscription_duration_months or 1 + ) + date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A" + return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" + + lines = [_format_item(p) for p in user_payments] + text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) + try: + split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) + except Exception: + split_pm_id_for_back = "" + from bot.keyboards.inline.user_keyboards import ( + get_back_to_payment_method_details_keyboard, + get_payment_methods_manage_keyboard, + ) + + back_markup = ( + get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n) + if split_pm_id_for_back + else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + ) + await callback.message.edit_text(text, reply_markup=back_markup) + + +@router.callback_query(F.data.startswith("pm:list:")) +async def payment_methods_list( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, 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 + + from db.dal.user_billing_dal import list_user_payment_methods + + cards: List[tuple] = [] + methods = await list_user_payment_methods(session, callback.from_user.id) + for m in methods: + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return get_text("payment_method_wallet_title", last4=l4) + return get_text("payment_method_wallet_title", last4="****") + if last4: + network_name = network or get_text("payment_network_card") + return get_text("payment_method_card_title", network=network_name, last4=last4) + network_name = network or get_text("payment_network_generic") + return get_text("payment_method_generic_title", network=network_name) + + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + + try: + _, _, page_str = callback.data.split(":", 2) + page = int(page_str) + except Exception: + page = 0 + + text = get_text("payment_methods_title") + if not cards: + text += "\n\n" + get_text("payment_method_none") + await callback.message.edit_text( + text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n) + ) + try: + await callback.answer() + except Exception: + pass + + +logger = logging.getLogger(__name__) + + +def create_service(ctx: ServiceFactoryContext) -> YooKassaService: + return YooKassaService( + shop_id=ctx.settings.YOOKASSA_SHOP_ID, + secret_key=ctx.settings.YOOKASSA_SECRET_KEY, + configured_return_url=ctx.settings.YOOKASSA_RETURN_URL, + bot_username_for_default_return=ctx.bot_username_for_default_return, + settings_obj=ctx.settings, + ) + + + + + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings = ctx.request.app["settings"] + service: YooKassaService = ctx.request.app["yookassa_service"] + if not service or not service.configured: + return payment_unavailable() + + try: + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + ) + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency="RUB", + status="pending_yookassa", + provider="yookassa", + ) + metadata = { + "user_id": str(ctx.user_id), + "subscription_months": str( + int(float(ctx.months)) + if not amounts.traffic_sale and not amounts.hwid_devices_sale + else 0 + ), + "payment_db_id": str(payment.payment_id), + "sale_mode": ctx.sale_mode, + "source": "webapp", + } + if amounts.traffic_sale: + metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) + if amounts.hwid_devices_sale: + metadata["hwid_devices"] = str(int(float(ctx.months))) + if amounts.tariff_key: + metadata["tariff_key"] = amounts.tariff_key + response = await service.create_payment( + amount=ctx.price, + currency="RUB", + description=ctx.description, + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=bool( + settings.yookassa_autopayments_active + and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING + ), + ) + payment_url = response.get("confirmation_url") if response else None + if not payment_url: + await mark_payment_failed_creation(ctx.session, payment.payment_id) + return payment_failed() + + await payment_dal.update_payment_status_by_db_id( + ctx.session, + payment.payment_id, + response.get("status", "pending"), + yk_payment_id=response.get("id"), + ) + await ctx.session.commit() + return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id) + except Exception: + await ctx.session.rollback() + logger.exception("YooKassa WebApp payment failed") + return payment_failed() + + +SPEC = PaymentProviderSpec( + id="yookassa", + provider_key="yookassa", + label="YooKassa", + webapp_label="Банковская карта", + webapp_labels={"ru": "Банковская карта", "en": "Bank card"}, + webapp_icon="CreditCard", + telegram_labels={"ru": "ЮKassa", "en": "YooKassa"}, + telegram_emoji="💳", + pending_status="pending_yookassa", + enabled=lambda settings: settings.YOOKASSA_ENABLED, + service_key="yookassa_service", + button_text_key="pay_with_yookassa_button", + callback_prefix="pay_yk", + router=router, + create_service=create_service, + webhook_path=lambda settings: settings.yookassa_webhook_path, + webhook_route=yookassa_webhook_route, + webhook_requires_base_url=True, + create_webapp_payment=create_webapp_payment, +) diff --git a/backend/bot/services/freekassa_service.py b/backend/bot/services/freekassa_service.py deleted file mode 100644 index 4439326..0000000 --- a/backend/bot/services/freekassa_service.py +++ /dev/null @@ -1,466 +0,0 @@ -import asyncio -import hashlib -import hmac -import json -import logging -import time -from datetime import datetime -from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Optional, Tuple -from urllib.parse import parse_qsl - -from aiogram import Bot -from aiohttp import ClientSession, ClientTimeout, web -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 FreeKassaService: - def __init__( - self, - *, - bot: Bot, - settings: Settings, - i18n: JsonI18n, - async_session_factory: sessionmaker, - subscription_service: SubscriptionService, - referral_service: ReferralService, - ): - 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.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID - self.api_key: Optional[str] = settings.FREEKASSA_API_KEY - self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET - self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP - self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID - - self.api_base_url: str = "https://api.fk.life/v1" - self._timeout = ClientTimeout(total=15) - self._session: Optional[ClientSession] = None - self._nonce_lock = asyncio.Lock() - self._last_nonce = int(time.time() * 1000) - - self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key) - if not self.configured: - logging.warning( - "FreeKassaService initialized but not fully configured. Payments disabled." - ) - if settings.FREEKASSA_ENABLED and not self.server_ip: - logging.warning( - "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 - ) - - @staticmethod - def _format_amount(amount: float) -> str: - """Format amount for payloads and signature with two decimal places.""" - quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - return f"{quantized:.2f}" - - async def create_order( - self, - *, - payment_db_id: int, - user_id: int, - months: int, - amount: float, - currency: Optional[str], - email: Optional[str] = None, - ip_address: Optional[str] = None, - payment_method_id: Optional[int] = None, - extra_params: Optional[Dict[str, Any]] = None, - ) -> Tuple[bool, Dict[str, Any]]: - if not self.configured: - logging.error("FreeKassaService is not configured. Cannot create order.") - return False, {"message": "service_not_configured"} - - ip_address = ip_address or self.server_ip - if not ip_address: - logging.error("FreeKassaService: payment IP is required but not configured.") - return False, {"message": "missing_ip"} - - email = email or f"{user_id}@telegram.org" - amount_str = self._format_amount(amount) - currency_code = (currency or self.default_currency or "RUB").upper() - - payload: Dict[str, Any] = { - "shopId": int(self.shop_id), - "nonce": await self._generate_nonce(), - "paymentId": str(payment_db_id), - "i": int(payment_method_id), - "amount": amount_str, - "currency": currency_code, - "email": email, - "ip": ip_address, - "us_user_id": str(user_id), - "us_months": str(months), - "us_payment_db_id": str(payment_db_id), - } - - if extra_params: - for key, value in extra_params.items(): - if value is None: - continue - payload[key] = value - - payload["signature"] = self._sign_payload(payload) - - session = await self._get_session() - url = f"{self.api_base_url}/orders/create" - try: - async with session.post(url, json=payload) as response: - response_text = await response.text() - try: - response_data = json.loads(response_text) if response_text else {} - except json.JSONDecodeError: - logging.error( - "FreeKassa create_order: failed to decode JSON: %s", response_text - ) - return False, { - "status": response.status, - "message": "invalid_json", - "raw": response_text, - } - - if response.status != 200 or response_data.get("type") != "success": - logging.error( - "FreeKassa create_order: API returned 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("FreeKassa create_order: request failed.") - return False, {"message": str(exc)} - - 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 _generate_nonce(self) -> int: - async with self._nonce_lock: - candidate = int(time.time() * 1000) - if candidate <= self._last_nonce: - candidate = self._last_nonce + 1 - self._last_nonce = candidate - return candidate - - def _sign_payload(self, payload: Dict[str, Any]) -> str: - if not self.api_key: - raise RuntimeError("FreeKassa API key is not configured.") - items = [ - (key, value) - for key, value in payload.items() - if key != "signature" and value is not None - ] - items.sort(key=lambda pair: pair[0]) - message = "|".join(str(value) for _, value in items) - return hmac.new( - self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256 - ).hexdigest() - - async def close(self) -> None: - if self._session and not self._session.closed: - await self._session.close() - - def _validate_signature( - self, - raw_body: bytes, - provided_signature: str, - ) -> bool: - if not provided_signature: - return False - if not self.second_secret: - return False - - expected_signature = hmac.new( - self.second_secret.encode("utf-8"), - raw_body, - hashlib.sha256, - ).hexdigest() - return hmac.compare_digest(expected_signature, provided_signature) - - async def webhook_route(self, request: web.Request) -> web.Response: - if not self.configured: - return web.Response(status=503, text="freekassa_disabled") - - try: - client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) - if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips): - return web.Response(status=403) - - raw_body = await request.read() - except Exception: - logging.exception("FreeKassa webhook: failed to read request body.") - return web.Response(status=400, text="bad_request") - - payload_dict: Dict[str, Any] = {} - if raw_body: - try: - if request.content_type.startswith("application/json"): - decoded_json = json.loads(raw_body.decode("utf-8")) - if isinstance(decoded_json, dict): - payload_dict = {str(k): v for k, v in decoded_json.items()} - else: - payload_dict = { - str(key): value - for key, value in parse_qsl( - raw_body.decode("utf-8"), keep_blank_values=True - ) - } - except Exception: - payload_dict = {} - - def _get(key: str, default: Optional[str] = None) -> Optional[str]: - return payload_dict.get(key) or payload_dict.get(key.lower()) or default - - merchant_id = _get("MERCHANT_ID") - if merchant_id != self.shop_id: - return web.Response(status=403) - - signature = _get("SIGN") or _get("signature") - if not signature: - return web.Response(status=400, text="missing_signature") - - order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") - amount_str = _get("AMOUNT") or _get("OA") or _get("amount") - provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id") - - if not order_id_str or not amount_str: - return web.Response(status=400, text="missing_data") - - if not self._validate_signature(raw_body, signature): - return web.Response(status=403, text="invalid_signature") - - try: - payment_db_id = int(order_id_str) - except (TypeError, ValueError): - logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'") - return web.Response(status=400, text="invalid_order_id") - - async with self.async_session_factory() as session: - payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) - if not payment: - logging.error(f"FreeKassa webhook: payment {payment_db_id} not found") - return web.Response(status=404, text="payment_not_found") - - if payment.status == "succeeded": - logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded") - return web.Response(text="YES") - - # Optional amount verification - try: - amount_decimal = Decimal(amount_str) - expected_amount = Decimal(str(payment.amount)).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) - if ( - amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - != expected_amount - ): - logging.warning( - f"FreeKassa webhook: amount mismatch for payment {payment_db_id} " - f"(expected {expected_amount}, got {amount_decimal})" - ) - except Exception as e: - logging.warning( - f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}" - ) - - activation = None - referral_bonus = None - try: - await payment_dal.update_provider_payment_and_status( - session=session, - payment_db_id=payment.payment_id, - provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"), - new_status="succeeded", - ) - - months = 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] - - activation = await self.subscription_service.activate_subscription( - session, - payment.user_id, - int(months) if sale_base == "subscription" else int(float(months)), - float(payment.amount), - payment.payment_id, - provider="freekassa", - sale_mode=sale_mode, - traffic_gb=float(months) - 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(months), - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) - - await session.commit() - except Exception: - await session.rollback() - logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_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 - - 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 - months = 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] - - applied_days = 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) - - if not final_end and activation and activation.get("end_date"): - final_end = activation["end_date"] - - if final_end: - end_date_str = final_end.strftime("%Y-%m-%d") - else: - end_date_str = _("config_link_not_available") - - traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}" - - if sale_mode.split("@", 1)[0].split("|", 1)[0] in { - "traffic", - "traffic_package", - "topup", - "premium_topup", - }: - text = _( - "payment_successful_traffic_full", - traffic_gb=traffic_label, - end_date=end_date_str 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=months, - base_end_date=activation["end_date"].strftime("%Y-%m-%d") - if activation and activation.get("end_date") - else end_date_str, - bonus_days=applied_days, - final_end_date=end_date_str, - inviter_name=inviter_name_display, - config_link=config_link_text, - ) - else: - text = _( - "payment_successful_full", - months=months, - end_date=end_date_str, - config_link=config_link_text, - ) - if provider_payment_id: - order_info_text = _( - "free_kassa_order_full", - order_id=provider_payment_id, - date=datetime.now().strftime("%Y-%m-%d"), - ) - text = f"{order_info_text}\n{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( - "FreeKassa notification: failed to send message to 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=self.default_currency, - months=int(months) if sale_base == "subscription" else 0, - traffic_gb=float(months) - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - payment_provider="freekassa", - 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("FreeKassa notification: failed to notify admins.") - - return web.Response(text="YES") - - -async def freekassa_webhook_route(request: web.Request) -> web.Response: - service: FreeKassaService = request.app["freekassa_service"] - return await service.webhook_route(request) diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index d6ca7e4..ef81724 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -375,15 +375,12 @@ class NotificationService: username=username, ) - provider_emoji = { - "wata": "💳", - "yookassa": "💳", - "freekassa": "💳", - "cryptopay": "₿", - "stars": "⭐", - "platega": "💳", - "severpay": "💳", - }.get(payment_provider.lower(), "💰") + try: + from bot.payment_providers import provider_emoji_map + + provider_emoji = provider_emoji_map(self.settings).get(payment_provider.lower(), "💰") + except Exception: + provider_emoji = "💰" if traffic_gb is not None: traffic_label = self._format_traffic_gb_admin(float(traffic_gb)) diff --git a/backend/bot/services/platega_service.py b/backend/bot/services/platega_service.py deleted file mode 100644 index f8e23c2..0000000 --- a/backend/bot/services/platega_service.py +++ /dev/null @@ -1,421 +0,0 @@ -import hmac -import json -import logging -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 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.text_sanitizer import sanitize_display_name, username_for_display -from config.settings import Settings -from db.dal import payment_dal, user_dal - - -class PlategaService: - 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.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/") - self.merchant_id = settings.PLATEGA_MERCHANT_ID - self.secret = settings.PLATEGA_SECRET - self.payment_method = settings.PLATEGA_PAYMENT_METHOD - self.sbp_method = settings.platega_sbp_method_resolved - self.crypto_method = settings.PLATEGA_CRYPTO_METHOD - self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}" - self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url - - self._timeout = ClientTimeout(total=20) - self._session: Optional[ClientSession] = None - self._auth_headers = { - "X-MerchantId": self.merchant_id or "", - "X-Secret": self.secret or "", - "Content-Type": "application/json", - } - self.configured: bool = bool(settings.PLATEGA_ENABLED and self.merchant_id and self.secret) - if not self.configured: - logging.warning( - "PlategaService initialized but not fully configured. Payments disabled." - ) - else: - logging.info( - "PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501 - "ON" if settings.PLATEGA_SBP_ENABLED else "OFF", - self.sbp_method, - "ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF", - self.crypto_method, - ) - - 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() - - async def create_transaction( - self, - *, - payment_db_id: int, - user_id: int, - months: int, - amount: float, - currency: Optional[str], - description: str, - payload: Optional[str] = None, - payment_method: Optional[int] = None, - ) -> Tuple[bool, Dict[str, Any]]: - if not self.configured: - logging.error("PlategaService is not configured. Cannot create transaction.") - return False, {"message": "service_not_configured"} - - session = await self._get_session() - url = f"{self.base_url}/transaction/process" - currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - method_id = int(payment_method if payment_method is not None else self.payment_method) - - body: Dict[str, Any] = { - "paymentMethod": method_id, - "paymentDetails": {"amount": float(amount), "currency": currency_code}, - "description": description, - "return": self.return_url, - "failedUrl": self.failed_url, - "payload": payload, - } - - # Remove optional keys with falsy values to avoid validation errors - clean_body = {k: v for k, v in body.items() if v not in (None, "")} - safe_headers = { - "X-MerchantId": self._auth_headers.get("X-MerchantId"), - "X-Secret": "***" if self._auth_headers.get("X-Secret") else "", - "Content-Type": self._auth_headers.get("Content-Type"), - } - logging.info( - "Platega create_transaction request: url=%s headers=%s body=%s", - url, - safe_headers, - clean_body, - ) - - try: - async with session.post(url, json=clean_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( - "Platega create_transaction: invalid JSON response: %s", response_text - ) - return False, { - "status": response.status, - "message": "invalid_json", - "raw": response_text, - } - - if response.status != 200: - logging.error( - "Platega create_transaction: API returned 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("Platega create_transaction: request failed.") - return False, {"message": str(exc)} - - async def webhook_route(self, request: web.Request) -> web.Response: - if not self.configured: - return web.Response(status=503, text="platega_disabled") - - try: - data = await request.json() - except Exception: - logging.exception("Platega webhook: failed to parse JSON.") - return web.Response(status=400, text="bad_request") - - header_merchant = request.headers.get("X-MerchantId") - header_secret = request.headers.get("X-Secret") - if not ( - hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or "")) - and hmac.compare_digest(str(header_secret or ""), str(self.secret or "")) - ): - logging.error("Platega webhook: invalid auth headers") - return web.Response(status=403, text="forbidden") - - transaction_id = str(data.get("id") or data.get("transactionId") or "").strip() - status = str(data.get("status") or "").upper() - amount_raw = data.get("amount") - currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - if not transaction_id or not status: - logging.error("Platega webhook: missing transaction id or status in payload: %s", data) - return web.Response(status=400, text="missing_fields") - - async with self.async_session_factory() as session: - payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id) - if not payment: - logging.error( - "Platega webhook: payment not found for transaction %s", transaction_id - ) - return web.Response(status=404, text="payment_not_found") - - if payment.status == "succeeded" and status == "CONFIRMED": - return web.Response(text="ok") - - payment_months = 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 status == "CONFIRMED": - if amount_raw is not None: - try: - incoming_amount = Decimal(str(amount_raw)).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) - expected_amount = Decimal(str(payment.amount)).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) - if incoming_amount != expected_amount: - logging.warning( - "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501 - payment.payment_id, - expected_amount, - incoming_amount, - ) - except Exception as exc: - logging.warning( - "Platega 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_months) - if sale_base == "subscription" - else int(float(payment_months)), - float(payment.amount), - payment.payment_id, - provider="platega", - sale_mode=sale_mode, - traffic_gb=float(payment_months) - 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_months), - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) - ) - - await session.commit() - except Exception: - await session.rollback() - logging.exception( - "Platega webhook: failed to process payment %s.", transaction_id - ) - return web.Response(status=500, text="processing_error") - - db_user = 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) - - traffic_label = ( - str(int(payment_months)) - if float(payment_months).is_integer() - else f"{payment_months:g}" - ) - - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - text = _( - "payment_successful_traffic_full", - traffic_gb=traffic_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_months, - 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_months, - 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_months, - 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("Platega 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_months) if sale_base == "subscription" else 0, - traffic_gb=float(payment_months) - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - payment_provider="platega", - 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("Platega webhook: failed to notify admins.") - - return web.Response(text="ok") - - if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - transaction_id, - "canceled", - ) - await session.commit() - except Exception: - await session.rollback() - logging.exception( - "Platega webhook: failed to cancel payment %s.", transaction_id - ) - return web.Response(status=500, text="processing_error") - - db_user = 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: - pass - return web.Response(text="ok_canceled") - - logging.warning( - "Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id - ) - return web.Response(status=202, text="status_ignored") - - -async def platega_webhook_route(request: web.Request) -> web.Response: - service: PlategaService = request.app["platega_service"] - return await service.webhook_route(request) diff --git a/backend/bot/services/severpay_service.py b/backend/bot/services/severpay_service.py deleted file mode 100644 index f044fed..0000000 --- a/backend/bot/services/severpay_service.py +++ /dev/null @@ -1,444 +0,0 @@ -import hashlib -import hmac -import json -import logging -import secrets -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 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.text_sanitizer import sanitize_display_name, username_for_display -from config.settings import Settings -from db.dal import payment_dal, user_dal - - -class SeverPayService: - 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.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip( - "/" - ) - self.mid = settings.SEVERPAY_MID - self.token = settings.SEVERPAY_TOKEN or "" - self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}" - self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES - - self._timeout = ClientTimeout(total=15) - self._session: Optional[ClientSession] = None - - self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token) - if not self.configured: - logging.warning( - "SeverPayService 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) -> str: - quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - return f"{quantized:.2f}" - - def _sign_payload(self, payload: Dict[str, Any]) -> str: - message = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - return hmac.new( - self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256 - ).hexdigest() - - def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]: - body: Dict[str, Any] = { - "mid": self.mid, - "salt": secrets.token_hex(8), - } - body.update(extra) - sorted_body = dict(sorted(body.items())) - sorted_body["sign"] = self._sign_payload(sorted_body) - return sorted_body - - def _validate_signature(self, payload: Dict[str, Any]) -> bool: - provided_sign = str(payload.get("sign") or "") - if not provided_sign or not self.token: - return False - # Webhook signatures are calculated on the original payload order (without sorting). - data = {k: v for k, v in payload.items() if k != "sign"} - expected_sign = self._sign_payload(data) - return hmac.compare_digest(provided_sign, expected_sign) - - async def create_payment( - self, - *, - payment_db_id: int, - user_id: int, - months: int, - amount: float, - currency: Optional[str], - description: str, - ) -> Tuple[bool, Dict[str, Any]]: - if not self.configured: - logging.error("SeverPayService is not configured. Cannot create payment.") - return False, {"message": "service_not_configured"} - - session = await self._get_session() - url = f"{self.base_url}/payin/create" - currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - amount_str = self._format_amount(amount) - - body = { - "order_id": str(payment_db_id), - "amount": amount_str, - "currency": currency_code, - "client_email": f"{user_id}@telegram.org", - "client_id": str(user_id), - "url_return": self.return_url, - } - - if self.lifetime_minutes: - body["lifetime"] = int(self.lifetime_minutes) - - signed_body = self._build_signed_body(body) - - try: - async with session.post(url, json=signed_body) as response: - response_text = await response.text() - try: - response_data = json.loads(response_text) if response_text else {} - except json.JSONDecodeError: - logging.error( - "SeverPay create_payment: invalid JSON response: %s", response_text - ) - return False, { - "status": response.status, - "message": "invalid_json", - "raw": response_text, - } - - if response.status != 200 or not response_data.get("status"): - logging.error( - "SeverPay create_payment: API returned error (status=%s, body=%s)", - response.status, - response_data, - ) - return False, {"status": response.status, "message": response_data} - - return True, response_data.get("data") or response_data - except Exception as exc: - logging.exception("SeverPay create_payment: request failed.") - return False, {"message": str(exc)} - - async def webhook_route(self, request: web.Request) -> web.Response: - if not self.configured: - return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) - - try: - payload = await request.json() - except Exception: - logging.exception("SeverPay webhook: failed to parse JSON.") - return web.json_response({"status": False, "msg": "bad_request"}, status=400) - - if not isinstance(payload, dict) or not self._validate_signature(payload): - logging.error("SeverPay webhook: invalid signature or payload.") - return web.json_response({"status": False, "msg": "invalid_signature"}, status=403) - - event_type = str(payload.get("type") or "").lower() - data = payload.get("data") or {} - - if event_type != "payin" or not isinstance(data, dict): - logging.warning("SeverPay webhook: unsupported event type '%s'", event_type) - return web.json_response({"status": True}) - - provider_payment_id = str(data.get("id") or data.get("uid") or "") - order_id_raw = data.get("order_id") - status = str(data.get("status") or "").lower() - - payment_db_id: Optional[int] = None - try: - 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) - except Exception: - payment_db_id = None - - 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 provider_payment_id: - payment = await payment_dal.get_payment_by_provider_payment_id( - session, provider_payment_id - ) - - if not payment: - logging.error( - "SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", - order_id_raw, - provider_payment_id, - ) - return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) - - payment_months = 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 status == "success": - try: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - provider_payment_id or str(payment.payment_id), - "succeeded", - ) - - activation = await self.subscription_service.activate_subscription( - session, - payment.user_id, - int(payment_months) - if sale_base == "subscription" - else int(float(payment_months)), - float(payment.amount), - payment.payment_id, - provider="severpay", - sale_mode=sale_mode, - traffic_gb=float(payment_months) - 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_months), - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) - ) - - await session.commit() - except Exception: - await session.rollback() - logging.exception( - "SeverPay webhook: failed to process payment %s.", provider_payment_id - ) - return web.json_response( - {"status": False, "msg": "processing_error"}, status=500 - ) - - 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) - - traffic_label = ( - str(int(payment_months)) - if float(payment_months).is_integer() - else f"{payment_months:g}" - ) - - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - text = _( - "payment_successful_traffic_full", - traffic_gb=traffic_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_months, - 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_months, - 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_months, - 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( - "SeverPay 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=payment.currency, - months=int(payment_months) if sale_base == "subscription" else 0, - traffic_gb=float(payment_months) - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - payment_provider="severpay", - 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("SeverPay webhook: failed to notify admins.") - - return web.json_response({"status": True}) - - if status in {"fail", "decline"}: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - provider_payment_id or str(payment.payment_id), - "failed", - ) - await session.commit() - except Exception: - await session.rollback() - logging.exception( - "SeverPay webhook: failed to mark payment %s as failed.", - provider_payment_id, - ) - return web.json_response( - {"status": False, "msg": "processing_error"}, status=500 - ) - - 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: - pass - return web.json_response({"status": True}) - - if status in {"process", "new"}: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - provider_payment_id or str(payment.payment_id), - "pending_severpay", - ) - await session.commit() - except Exception: - await session.rollback() - logging.exception( - "SeverPay webhook: failed to update pending status for %s.", - provider_payment_id, - ) - return web.json_response({"status": True}) - - logging.warning( - "SeverPay webhook: unhandled status '%s' for payment %s", - status, - provider_payment_id, - ) - return web.json_response({"status": True}) - - -async def severpay_webhook_route(request: web.Request) -> web.Response: - service: SeverPayService = request.app["severpay_service"] - return await service.webhook_route(request) diff --git a/backend/bot/services/stars_service.py b/backend/bot/services/stars_service.py deleted file mode 100644 index 715a548..0000000 --- a/backend/bot/services/stars_service.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging -from typing import Optional - -from aiogram import Bot, types -from aiogram.types import LabeledPrice -from sqlalchemy.ext.asyncio import AsyncSession - -from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from bot.middlewares.i18n import JsonI18n -from bot.utils.config_link import prepare_config_links -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 - -from .notification_service import NotificationService -from .referral_service import ReferralService -from .subscription_service import SubscriptionService - - -class StarsService: - def __init__( - self, - bot: Bot, - settings: Settings, - i18n: JsonI18n, - subscription_service: SubscriptionService, - referral_service: ReferralService, - ): - self.bot = bot - self.settings = settings - self.i18n = i18n - self.subscription_service = subscription_service - self.referral_service = referral_service - - async def create_invoice( - self, - session: AsyncSession, - user_id: int, - months: int, - stars_price: int, - description: str, - sale_mode: str = "subscription", - ) -> Optional[int]: - sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] - payment_record_data = { - "user_id": user_id, - "amount": float(stars_price), - "currency": "XTR", - "status": "pending_stars", - "description": description, - "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "provider": "telegram_stars", - "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, - } - try: - db_payment_record = await payment_dal.create_payment_record( - session, payment_record_data - ) - await session.commit() - except Exception as e_db: - await session.rollback() - logging.error(f"Failed to create stars payment record: {e_db}", exc_info=True) - return None - - payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}" - prices = [LabeledPrice(label=description, amount=stars_price)] - try: - await self.bot.send_invoice( - chat_id=user_id, - title=description, - description=description, - payload=payload, - provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. # noqa: E501 - currency="XTR", - prices=prices, - ) - return db_payment_record.payment_id - except Exception as e_inv: - logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", exc_info=True) - return None - - async def process_successful_payment( - self, - session: AsyncSession, - message: types.Message, - payment_db_id: int, - months: int, - stars_amount: int, - i18n_data: dict, - sale_mode: str = "subscription", - ) -> None: - try: - payment_record = await payment_dal.update_provider_payment_and_status( - session, - payment_db_id, - message.successful_payment.provider_payment_charge_id, - "succeeded", - ) - target_user_id = ( - int(payment_record.user_id) - if payment_record and payment_record.user_id is not None - else int(message.from_user.id) - ) - await session.commit() - except Exception as e_upd: - await session.rollback() - logging.error( - f"Failed to update stars payment record {payment_db_id}: {e_upd}", exc_info=True - ) - return - - sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] - activation_details = await self.subscription_service.activate_subscription( - session, - target_user_id, - int(months) if sale_base == "subscription" else int(float(months)), - float(stars_amount), - payment_db_id, - provider="telegram_stars", - sale_mode=sale_mode, - traffic_gb=months - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - ) - if not activation_details or not activation_details.get("end_date"): - logging.error( - f"Failed to activate subscription after stars payment for user {target_user_id}" - ) - return - - referral_bonus = None - if sale_base == "subscription": - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - target_user_id, - int(months) or 1, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) - await session.commit() - - applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None - final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None - if not final_end: - final_end = activation_details["end_date"] - - # Always use user's language from DB for user-facing messages - db_user = await user_dal.get_user_by_id(session, target_user_id) - current_lang = ( - db_user.language_code - if db_user and db_user.language_code - else self.settings.DEFAULT_LANGUAGE - ) - i18n: JsonI18n = i18n_data.get("i18n_instance") - _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k - - raw_config_link = activation_details.get("subscription_url") if activation_details 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") - - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: - success_msg = _( - "payment_successful_traffic_full", - traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", - end_date=final_end.strftime("%Y-%m-%d"), - config_link=config_link_text, - ) - elif applied_days: - inviter_name_display = _("friend_placeholder") - db_user = await user_dal.get_user_by_id(session, target_user_id) - 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) - success_msg = _( - "payment_successful_with_referral_bonus_full", - months=months, - base_end_date=activation_details["end_date"].strftime("%Y-%m-%d"), - bonus_days=applied_days, - final_end_date=final_end.strftime("%Y-%m-%d"), - inviter_name=inviter_name_display, - config_link=config_link_text, - ) - else: - success_msg = _( - "payment_successful_full", - months=months, - end_date=final_end.strftime("%Y-%m-%d"), - config_link=config_link_text, - ) - markup = get_connect_and_main_keyboard( - current_lang, - i18n, - self.settings, - config_link_display, - connect_button_url=connect_button_url, - preserve_message=True, - ) - try: - await self.bot.send_message( - message.from_user.id, - success_msg, - reply_markup=markup, - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as e_send: - logging.error(f"Failed to send stars payment success message: {e_send}") - - # Send notification about payment - try: - notification_service = NotificationService(self.bot, self.settings, self.i18n) - user = await user_dal.get_user_by_id(session, target_user_id) - await notification_service.notify_payment_received( - user_id=target_user_id, - amount=float(stars_amount), - currency="XTR", - months=int(months) if sale_base == "subscription" else 0, - payment_provider="stars", - username=user.username if user else None, - traffic_gb=float(months) - if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, - traffic_is_premium=sale_base == "premium_topup", - tariff_key=getattr(payment_record, "tariff_key", None), - ) - except Exception as e: - logging.error(f"Failed to send stars payment notification: {e}") diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index ca81d04..e5e05c7 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -85,7 +85,15 @@ class PaymentContextMixin: return end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" - provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) + try: + from bot.payment_providers import provider_label_map + + provider_label = provider_label_map( + self.settings, + db_user.language_code or self.settings.DEFAULT_LANGUAGE, + ).get((provider or "").lower()) + except Exception: + provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None try: diff --git a/backend/bot/services/wata_service.py b/backend/bot/services/wata_service.py deleted file mode 100644 index c86b296..0000000 --- a/backend/bot/services/wata_service.py +++ /dev/null @@ -1,489 +0,0 @@ -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/bot/services/yookassa_service.py b/backend/bot/services/yookassa_service.py deleted file mode 100644 index be1e52a..0000000 --- a/backend/bot/services/yookassa_service.py +++ /dev/null @@ -1,268 +0,0 @@ -import asyncio -import logging -import uuid -from typing import Any, Dict, List, Optional - -from yookassa import Configuration -from yookassa import Payment as YooKassaPayment -from yookassa.domain.common.confirmation_type import ConfirmationType -from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder - -from config.settings import Settings - - -class YooKassaService: - def __init__( - self, - shop_id: Optional[str], - secret_key: Optional[str], - configured_return_url: Optional[str], - bot_username_for_default_return: Optional[str] = None, - settings_obj: Optional[Settings] = None, - ): - - self.settings = settings_obj - - if self.settings and not self.settings.YOOKASSA_ENABLED: - logging.warning( - "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 - ) - self.configured = False - elif not shop_id or not secret_key: - logging.warning( - "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " - "Payment functionality will be DISABLED." - ) - self.configured = False - else: - try: - Configuration.configure(shop_id, secret_key) - self.configured = True - logging.info(f"YooKassa SDK configured for shop_id: {shop_id[:5]}...") - except Exception: - logging.exception("Failed to configure YooKassa SDK.") - self.configured = False - - if configured_return_url: - self.return_url = configured_return_url - elif bot_username_for_default_return: - self.return_url = f"https://t.me/{bot_username_for_default_return}" - logging.info( - f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501 - ) - else: - self.return_url = "https://example.com/payment_error_no_return_url_configured" - logging.warning( - f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. " - f"Using placeholder: {self.return_url}. Payments may not complete correctly." - ) - logging.info(f"YooKassa Service effective return_url for payments: {self.return_url}") - - async def create_payment( - self, - amount: float, - currency: str, - description: str, - metadata: Dict[str, Any], - receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None, - save_payment_method: bool = False, - payment_method_id: Optional[str] = None, - capture: bool = True, - bind_only: bool = False, - ) -> Optional[Dict[str, Any]]: - if not self.configured: - logging.error("YooKassa is not configured. Cannot create payment.") - return None - - if not self.settings: - logging.error( - "YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501 - ) - return { - "error": True, - "internal_message": "Service settings (Settings object) not initialized.", - } - - customer_contact_for_receipt = {} - if receipt_email: - customer_contact_for_receipt["email"] = receipt_email - elif receipt_phone: - customer_contact_for_receipt["phone"] = receipt_phone - elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: - customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - else: - logging.error( - "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501 - ) - return { - "error": True, - "internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501 - } - - try: - builder = PaymentRequestBuilder() - builder.set_amount({"value": str(round(amount, 2)), "currency": currency.upper()}) - # For binding cards only, do not capture and set minimal amount - if bind_only: - capture = False - amount = max(amount, 1.00) - builder.set_capture(capture) - if not payment_method_id: - # Saved payment_method_id charges must omit confirmation per YooKassa API - builder.set_confirmation( - {"type": ConfirmationType.REDIRECT, "return_url": self.return_url} - ) - builder.set_description(description) - builder.set_metadata(metadata) - if save_payment_method: - # Ask YooKassa to save method for off-session charges - builder.set_save_payment_method(True) - if payment_method_id: - # Use a previously saved payment method for merchant-initiated payments - builder.set_payment_method_id(payment_method_id) - - receipt_items_list: List[Dict[str, Any]] = [ - { - "description": description[:128], - "quantity": "1.00", - "amount": {"value": str(round(amount, 2)), "currency": currency.upper()}, - "vat_code": str(self.settings.YOOKASSA_VAT_CODE), - "payment_mode": getattr( - self.settings, - "yk_receipt_payment_mode", - self.settings.YOOKASSA_PAYMENT_MODE, - ), - "payment_subject": getattr( - self.settings, - "yk_receipt_payment_subject", - self.settings.YOOKASSA_PAYMENT_SUBJECT, - ), - } - ] - - receipt_data_dict: Dict[str, Any] = { - "customer": customer_contact_for_receipt, - "items": receipt_items_list, - } - - builder.set_receipt(receipt_data_dict) - - idempotence_key = str(uuid.uuid4()) - payment_request = builder.build() - - logging.info( - f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). " - f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}" - ) - - response = await asyncio.to_thread( - YooKassaPayment.create, - payment_request, - idempotence_key, - ) - - logging.info( - f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501 - ) - - return { - "id": response.id, - "confirmation_url": response.confirmation.confirmation_url - if response.confirmation - else None, - "status": response.status, - "metadata": response.metadata, - "amount_value": float(response.amount.value), - "amount_currency": response.amount.currency, - "idempotence_key_used": idempotence_key, - "paid": response.paid, - "refundable": response.refundable, - "created_at": response.created_at.isoformat() - if hasattr(response.created_at, "isoformat") - else str(response.created_at), - "description_from_yk": response.description, - "test_mode": response.test if hasattr(response, "test") else None, - "payment_method": getattr(response, "payment_method", None), - } - except Exception: - logging.exception("YooKassa payment creation failed.") - return None - - async def get_payment_info(self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]: - if not self.configured: - logging.error("YooKassa is not configured. Cannot get payment info.") - return None - try: - logging.info(f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}") - - payment_info_yk = await asyncio.to_thread( - YooKassaPayment.find_one, - payment_id_in_yookassa, - ) - - if payment_info_yk: - logging.info( - f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501 - ) - pm = getattr(payment_info_yk, "payment_method", None) - pm_payload: Dict[str, Any] = {} - if pm: - # Collect common fields, including id and hints for last4 - pm_id = getattr(pm, "id", None) - pm_type = getattr(pm, "type", None) - pm_title = getattr(pm, "title", None) - account_number = getattr(pm, "account_number", None) or getattr( - pm, "account", None - ) - card_obj = getattr(pm, "card", None) - last4_val = None - if card_obj and hasattr(card_obj, "last4"): - last4_val = getattr(card_obj, "last4") - elif isinstance(account_number, str) and len(account_number) >= 4: - last4_val = account_number[-4:] - pm_payload = { - "id": pm_id, - "type": pm_type, - "title": pm_title, - "card_last4": last4_val, - } - return { - "id": payment_info_yk.id, - "status": payment_info_yk.status, - "paid": payment_info_yk.paid, - "amount_value": float(payment_info_yk.amount.value), - "amount_currency": payment_info_yk.amount.currency, - "metadata": payment_info_yk.metadata, - "description": payment_info_yk.description, - "refundable": payment_info_yk.refundable, - "created_at": payment_info_yk.created_at.isoformat() - if hasattr(payment_info_yk.created_at, "isoformat") - else str(payment_info_yk.created_at), - "captured_at": payment_info_yk.captured_at.isoformat() - if getattr(payment_info_yk, "captured_at", None) - and hasattr(payment_info_yk.captured_at, "isoformat") - else None, - "payment_method": pm_payload, - "test_mode": getattr(payment_info_yk, "test", None), - } - else: - logging.warning( - f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}" - ) - return None - except Exception: - logging.exception("YooKassa get payment info for %s failed.", payment_id_in_yookassa) - return None - - async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: - if not self.configured: - logging.error("YooKassa is not configured. Cannot cancel payment.") - return False - try: - await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa) - logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") - return True - except Exception: - logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa) - return False diff --git a/backend/config/settings.py b/backend/config/settings.py index 5abb702..363c085 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -289,6 +289,54 @@ class Settings(BaseSettings): default=None, description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 ) + PAYMENT_FREEKASSA_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_FREEKASSA_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_FREEKASSA_WEBAPP_ICON: Optional[str] = None + PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_FREEKASSA_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_PLATEGA_SBP_WEBAPP_ICON: Optional[str] = None + PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_PLATEGA_SBP_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_WEBAPP_ICON: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_PLATEGA_CRYPTO_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_SEVERPAY_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_SEVERPAY_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_SEVERPAY_WEBAPP_ICON: Optional[str] = None + PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_SEVERPAY_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_WATA_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_WATA_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_WATA_WEBAPP_ICON: Optional[str] = None + PAYMENT_WATA_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_WATA_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_WATA_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_YOOKASSA_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_YOOKASSA_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_YOOKASSA_WEBAPP_ICON: Optional[str] = None + PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_YOOKASSA_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_STARS_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_STARS_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_STARS_WEBAPP_ICON: Optional[str] = None + PAYMENT_STARS_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_STARS_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_STARS_TELEGRAM_EMOJI: Optional[str] = None + PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU: Optional[str] = None + PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN: Optional[str] = None + PAYMENT_CRYPTOPAY_WEBAPP_ICON: Optional[str] = None + PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU: Optional[str] = None + PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN: Optional[str] = None + PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI: Optional[str] = None MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") diff --git a/backend/main_worker.py b/backend/main_worker.py index f86b730..075a68e 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -13,16 +13,16 @@ import db.database_setup as database_setup from app_logging import configure_logging from bot.app.factories.build_services import build_core_services from bot.handlers.admin.sync_admin import perform_sync -from bot.handlers.user.payment import ( +from bot.infra.redis import close_redis, redis_lock +from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth +from bot.middlewares.i18n import get_i18n_instance +from bot.payment_providers.yookassa import ( YOOKASSA_EVENT_PAYMENT_CANCELED, YOOKASSA_EVENT_PAYMENT_SUCCEEDED, payment_processing_lock, process_cancelled_payment, process_successful_payment, ) -from bot.infra.redis import close_redis, redis_lock -from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth -from bot.middlewares.i18n import get_i18n_instance from bot.services.settings_override_service import load_overrides_from_db from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager diff --git a/docs/admin.md b/docs/admin.md index e90956f..d9fa153 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -42,11 +42,13 @@ - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал и поведение `/start`; - внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; - legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика; -- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Stars; +- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Stars, а также текст и иконки кнопок оплаты; - пробный период, реферальные бонусы, уведомления, логирование, раздел устройств, лимит устройств и legacy-лимиты трафика. Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. +Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT__WEBAPP_LABEL_RU`, `PAYMENT__WEBAPP_LABEL_EN`, `PAYMENT__WEBAPP_ICON`, `PAYMENT__TELEGRAM_LABEL_RU`, `PAYMENT__TELEGRAM_LABEL_EN` и `PAYMENT__TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке. + ## Внешний вид Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. diff --git a/docs/configuration.md b/docs/configuration.md index 4a3003b..831c6b6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,8 @@ nano .env | Переменная | Назначение | | --- | --- | | `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты через запятую: `severpay`, `wata`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. | +| `PAYMENT__WEBAPP_LABEL_RU` / `PAYMENT__WEBAPP_LABEL_EN` / `PAYMENT__WEBAPP_ICON` | Необязательная мультиязычная кастомизация текста и lucide-иконки кнопки оплаты в Web App. | +| `PAYMENT__TELEGRAM_LABEL_RU` / `PAYMENT__TELEGRAM_LABEL_EN` / `PAYMENT__TELEGRAM_EMOJI` | Необязательная мультиязычная кастомизация текста и эмодзи кнопки оплаты в Telegram-боте. | | `YOOKASSA_ENABLED` | Включает YooKassa. | | `YOOKASSA_SHOP_ID` / `YOOKASSA_SECRET_KEY` | Данные магазина YooKassa. | | `YOOKASSA_RETURN_URL` | URL возврата пользователя после оплаты. | diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index a26d1a5..10cf3fa 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -1,6 +1,8 @@
{#each methods as method} - {@const meta = methodMeta(method)} + {@const icon = methodIcon(method)} {/each} diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js index 21f1142..18ea026 100644 --- a/frontend/src/lib/components/ui/icons.js +++ b/frontend/src/lib/components/ui/icons.js @@ -42,6 +42,7 @@ export { RefreshCw, Repeat2, Save, + Search, Send, Server, Settings, diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index c9609b9..01d624a 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -162,10 +162,10 @@ export const DEV_MOCK = { { months: 12, price: 2690, currency: "RUB", title: "12 месяцев" }, ], payment_methods: [ - { id: "yookassa", name: "Карта" }, - { id: "platega_sbp", name: "Telegram Pay" }, - { id: "cryptopay", name: "Криптовалюта" }, - { id: "freekassa", name: "Другие способы" }, + { id: "yookassa", name: "Карта", icon: "CreditCard" }, + { id: "platega_sbp", name: "Telegram Pay", icon: "CreditCard" }, + { id: "cryptopay", name: "Криптовалюта", icon: "Bitcoin" }, + { id: "freekassa", name: "Другие способы", icon: "Smartphone" }, ], referral: { code: "ABCD1234", diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index df35dd5..6353e47 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -1880,6 +1880,81 @@ min-width: 0; } +.admin-icon-picker-trigger { + min-width: 180px; + justify-content: flex-start; +} + +.admin-icon-picker-trigger span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-icon-picker-dialog { + width: min(100%, 720px); +} + +.admin-icon-picker-body { + display: grid; + gap: 14px; +} + +.admin-icon-picker-search { + position: relative; + display: block; +} + +.admin-icon-picker-search > svg { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--admin-muted); + pointer-events: none; +} + +.admin-icon-picker-search .input { + width: 100%; + padding-left: 36px; +} + +.admin-icon-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); + gap: 8px; + max-height: min(52vh, 460px); + overflow-y: auto; + padding-right: 4px; +} + +.admin-icon-picker-option { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 42px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--surface-muted); + color: var(--admin-text); + padding: 9px 10px; + text-align: left; + cursor: pointer; +} + +.admin-icon-picker-option span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-icon-picker-option:hover, +.admin-icon-picker-option.active { + border-color: color-mix(in srgb, var(--accent) 45%, var(--admin-border)); + background: color-mix(in srgb, var(--accent) 12%, var(--surface-muted)); +} + .admin-setting-control .admin-color { width: 36px; height: 36px; diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py index a875e1a..8cd98b4 100644 --- a/tests/test_auto_renew_wiring.py +++ b/tests/test_auto_renew_wiring.py @@ -9,7 +9,7 @@ Prior to the fix, ``charge_subscription_renewal`` did:: yk = None There is no ``yookassa_service`` module inside ``subscription_service_impl`` -(the real one lives in ``bot.services.yookassa_service``), so the import +(the real implementation lives in ``bot.payment_providers.yookassa``), so the import always raised ``ModuleNotFoundError``, ``yk`` became ``None``, and every auto-renew silently logged ``YooKassa unavailable for auto-renew`` and returned False — even though ``build_core_services`` had wired a real diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py index f7703ed..a987d69 100644 --- a/tests/test_build_services_wiring.py +++ b/tests/test_build_services_wiring.py @@ -20,9 +20,9 @@ from typing import Any from unittest.mock import MagicMock from bot.app.factories.build_services import build_core_services +from bot.payment_providers.yookassa import YooKassaService from bot.services.panel_webhook_service import PanelWebhookService from bot.services.subscription_service import SubscriptionService -from bot.services.yookassa_service import YooKassaService from config.settings import Settings diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py new file mode 100644 index 0000000..3e4aed2 --- /dev/null +++ b/tests/test_payment_provider_registry.py @@ -0,0 +1,291 @@ +import importlib +from pathlib import Path +from types import SimpleNamespace + +from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard +from bot.payment_providers import ( + get_provider_spec, + iter_provider_specs, + iter_service_keys, + pending_statuses, + provider_emoji_map, + provider_label_map, + provider_telegram_button_text, + resolve_provider_presentation, +) +from bot.payment_providers.shared import ( + format_number_for_payload, + payment_record_amounts, + sale_mode_base, + sale_mode_is_hwid_devices, + sale_mode_is_traffic, + sale_mode_tariff_key, +) +from config.settings import Settings + +_LEGACY_PROVIDER_FILES = [ + "backend/bot/services/yookassa_service.py", + "backend/bot/services/freekassa_service.py", + "backend/bot/services/platega_service.py", + "backend/bot/services/severpay_service.py", + "backend/bot/services/crypto_pay_service.py", + "backend/bot/services/stars_service.py", + "backend/bot/services/wata_service.py", + "backend/bot/handlers/user/payment.py", + "backend/bot/handlers/user/subscription/payment_methods.py", + "backend/bot/handlers/user/subscription/payments_yookassa.py", + "backend/bot/handlers/user/subscription/payments_freekassa.py", + "backend/bot/handlers/user/subscription/payments_platega.py", + "backend/bot/handlers/user/subscription/payments_severpay.py", + "backend/bot/handlers/user/subscription/payments_crypto.py", + "backend/bot/handlers/user/subscription/payments_stars.py", + "backend/bot/handlers/user/subscription/payments_wata.py", +] + +_PROVIDER_MODULES = { + "yookassa": "YooKassaService", + "freekassa": "FreeKassaService", + "platega": "PlategaService", + "severpay": "SeverPayService", + "cryptopay": "CryptoPayService", + "stars": "StarsService", + "wata": "WataService", +} + + +def test_legacy_provider_integration_files_are_removed(): + repo_root = Path(__file__).resolve().parents[1] + + for relative_path in _LEGACY_PROVIDER_FILES: + assert not (repo_root / relative_path).exists() + + +def test_every_provider_module_owns_its_service_and_spec(): + for module_name, service_class_name in _PROVIDER_MODULES.items(): + module = importlib.import_module(f"bot.payment_providers.{module_name}") + + assert hasattr(module, service_class_name) + assert hasattr(module, "SPEC") or hasattr(module, "SPECS") + + +def test_wata_is_registered_as_single_provider_module(): + spec = get_provider_spec("wata") + + assert spec is not None + assert spec.service_key == "wata_service" + assert spec.pending_status == "pending_wata" + assert spec.button_text_key == "pay_with_wata_button" + assert spec.callback_prefix == "pay_wata" + assert spec.router is not None + assert spec.create_service is not None + assert spec.webhook_route is not None + assert spec.create_webapp_payment is not None + + +def test_yookassa_provider_keeps_autorenew_entrypoints_local(): + yookassa = importlib.import_module("bot.payment_providers.yookassa") + spec = get_provider_spec("yookassa") + + assert spec is not None + assert spec.service_key == "yookassa_service" + assert spec.create_service is not None + assert spec.webhook_route is yookassa.yookassa_webhook_route + assert yookassa.payment_processing_lock is not None + assert callable(yookassa.process_successful_payment) + assert callable(yookassa.process_cancelled_payment) + assert callable(yookassa.yookassa_webhook_route) + + +def test_every_payment_method_has_registry_driven_webapp_creator(): + missing = [ + spec.id + for spec in iter_provider_specs() + if spec.create_webapp_payment is None + ] + + assert missing == [] + + +def test_service_keys_and_statuses_come_from_provider_specs(): + assert set(iter_service_keys()) == { + "yookassa_service", + "freekassa_service", + "platega_service", + "severpay_service", + "wata_service", + "stars_service", + "cryptopay_service", + } + assert set(pending_statuses()) >= { + "pending", + "pending_yookassa", + "pending_freekassa", + "pending_platega", + "pending_severpay", + "pending_wata", + "pending_cryptopay", + "pending_stars", + } + + +def test_provider_labels_and_emojis_include_storage_keys_and_method_aliases(): + labels = provider_label_map() + emojis = provider_emoji_map() + + assert labels["wata"] == "Wata" + assert labels["telegram_stars"] == "Telegram Stars" + assert labels["stars"] == "Telegram Stars" + assert labels["platega"] == "Platega" + assert labels["platega_sbp"] == "Platega" + assert labels["platega_crypto"] == "Platega" + assert emojis["stars"] == get_provider_spec("stars").default_telegram_emoji + assert emojis["telegram_stars"] == get_provider_spec("stars").default_telegram_emoji + assert emojis["cryptopay"] == get_provider_spec("cryptopay").default_telegram_emoji + + +def test_provider_presentation_resolves_defaults_and_overrides(): + spec = get_provider_spec("yookassa") + assert spec is not None + + default = resolve_provider_presentation(spec) + assert default.webapp_label == spec.webapp_label + assert default.webapp_icon == "CreditCard" + assert default.telegram_label == spec.telegram_labels["ru"] + assert default.telegram_emoji == spec.default_telegram_emoji + assert not default.telegram_customized + + settings = SimpleNamespace( + PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card in app", + PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards", + PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Card in bot", + PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💸", + ) + custom = resolve_provider_presentation(spec, settings, language="en") + assert custom.webapp_label == "Card in app" + assert custom.webapp_icon == "WalletCards" + assert custom.telegram_label == "Card in bot" + assert custom.telegram_emoji == "💸" + assert custom.telegram_customized + + +def test_provider_telegram_button_text_uses_provider_defaults_until_customized(): + spec = get_provider_spec("wata") + assert spec is not None + + translate = lambda key: f"i18n:{key}" + assert ( + provider_telegram_button_text(spec, SimpleNamespace(), translate, language="en") + == f"{spec.default_telegram_emoji} Wata" + ) + + settings = SimpleNamespace(PAYMENT_WATA_TELEGRAM_LABEL_EN="Pay Wata") + assert ( + provider_telegram_button_text(spec, settings, translate, language="en") + == f"{spec.default_telegram_emoji} Pay Wata" + ) + + +def test_provider_presentation_ignores_cross_language_override(): + spec = get_provider_spec("yookassa") + assert spec is not None + + settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта") + + assert ( + resolve_provider_presentation(spec, settings, language="en").webapp_label + == "Bank card" + ) + + +def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + PAYMENT_METHODS_ORDER="wata", + WATA_ENABLED=True, + PAYMENT_WATA_TELEGRAM_LABEL_EN="Wata custom", + PAYMENT_WATA_TELEGRAM_EMOJI="💸", + ) + i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key) + + markup = get_payment_method_keyboard( + months=1, + price=150, + stars_price=None, + currency_symbol_val="RUB", + lang="en", + i18n_instance=i18n, + settings=settings, + ) + + button = markup.inline_keyboard[0][0] + assert button.text == "💸 Wata custom" + assert button.callback_data == "pay_wata:1:150:subscription" + + +def test_provider_callbacks_are_built_from_specs(): + wata = get_provider_spec("wata") + stars = get_provider_spec("stars") + + assert wata is not None + assert stars is not None + assert ( + wata.callback_data( + value="1", + rub_price=150, + stars_price=None, + sale_mode="subscription", + ) + == "pay_wata:1:150:subscription" + ) + assert ( + stars.callback_data( + value="1", + rub_price=150, + stars_price=42, + sale_mode="subscription", + ) + == "pay_stars:1:42:subscription" + ) + assert stars.callback_data( + value="1", + rub_price=150, + stars_price=None, + sale_mode="subscription", + ) is None + + +def test_provider_visibility_uses_service_configuration(): + spec = get_provider_spec("wata") + assert spec is not None + + settings = SimpleNamespace(WATA_ENABLED=True) + assert spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=True)}) + assert not spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=False)}) + assert not spec.is_visible(SimpleNamespace(WATA_ENABLED=False), {}) + + +def test_common_sale_mode_helpers_cover_provider_payment_records(): + assert sale_mode_base("traffic_package@premium|anything") == "traffic_package" + assert sale_mode_is_traffic("premium_topup@vip") + assert sale_mode_is_hwid_devices("hwid_devices@vip") + assert sale_mode_tariff_key("subscription@vip") == "vip" + assert format_number_for_payload(10.0) == "10" + assert format_number_for_payload(10.5) == "10.5" + + traffic = payment_record_amounts(months=20, traffic_gb=20.5, sale_mode="topup@vip") + assert traffic.months == 20 + assert traffic.purchased_gb == 20.5 + assert traffic.purchased_hwid_devices is None + assert traffic.tariff_key == "vip" + assert traffic.traffic_sale + + hwid = payment_record_amounts(months=3, sale_mode="hwid_devices@vip") + assert hwid.months == 3 + assert hwid.purchased_gb is None + assert hwid.purchased_hwid_devices == 3 + assert hwid.tariff_key == "vip" + assert hwid.hwid_devices_sale diff --git a/tests/test_security.py b/tests/test_security.py index 7459ad9..bd40076 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -15,9 +15,9 @@ from bot.app.web.webapp_auth import ( create_webapp_session_token, verify_telegram_oauth_nonce, ) -from bot.handlers.user.payment import yookassa_webhook_route -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.freekassa_service import FreeKassaService +from bot.payment_providers.cryptopay import CryptoPayService +from bot.payment_providers.freekassa import FreeKassaService +from bot.payment_providers.yookassa import yookassa_webhook_route from bot.utils.request_security import request_client_ip from config.settings import Settings from db.database_setup import redacted_database_url diff --git a/tests/test_settings.py b/tests/test_settings.py index c093149..0b59f60 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -152,6 +152,27 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK") + def test_payment_button_presentation_env_values_are_available(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта", + PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card", + PAYMENT_YOOKASSA_WEBAPP_ICON="CreditCard", + PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU="Банковская карта", + PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Bank card", + PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💳", + ) + + self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_RU, "Карта") + self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_EN, "Card") + self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_ICON, "CreditCard") + self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU, "Банковская карта") + self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN, "Bank card") + self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_EMOJI, "💳") + def test_tariff_warning_levels_are_parsed(self): settings = Settings( _env_file=None, diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index b87bbf8..e3bc83e 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -358,6 +358,28 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(methods, []) + def test_serialize_payment_methods_includes_provider_presentation(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + PAYMENT_METHODS_ORDER="yookassa", + YOOKASSA_ENABLED=True, + PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта", + PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Bank card", + PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards", + ) + app = {"yookassa_service": SimpleNamespace(configured=True)} + + methods = subscription_webapp._serialize_payment_methods(settings, app, "en") + + self.assertEqual( + methods, + [{"id": "yookassa", "name": "Bank card", "icon": "WalletCards"}], + ) + def test_serialize_plans_includes_stars_only_subscription_options(self): settings = Settings( _env_file=None,