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 pydantic import Field, field_validator from pydantic_settings import SettingsConfigDict 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 config.tariffs_config import ( default_currency_key_for_settings, default_payment_currency_code_for_settings, ) from db.dal import payment_dal from .base import ( PaymentProviderSpec, ProviderEnvConfig, ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, normalize_payment_currency_code, parse_supported_currency_codes, provider_env_file, provider_runtime_enabled, ) 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, payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, ) _LOG = "severpay" class SeverPayConfig(ProviderEnvConfig): model_config = SettingsConfigDict( env_file=provider_env_file(), env_file_encoding="utf-8", env_prefix="SEVERPAY_", extra="ignore", ) ENABLED: bool = Field(default=False) MID: Optional[int] = None TOKEN: Optional[str] = None RETURN_URL: Optional[str] = None BASE_URL: str = Field(default="https://severpay.io/api/merchant") LIFETIME_MINUTES: Optional[int] = None SUPPORTED_CURRENCIES: str = Field(default="RUB,USD") @field_validator("MID", "LIFETIME_MINUTES", mode="before") @classmethod def _empty_to_none_int(cls, v): if isinstance(v, str): v = v.strip() if not v: return None return v @field_validator("TOKEN", "RETURN_URL", mode="before") @classmethod def _strip_optional(cls, v): if isinstance(v, str) and not v.strip(): return None return v @property def webhook_path(self) -> str: return "/webhook/severpay" class SeverPayPresentation(ProviderEnvConfig): model_config = SettingsConfigDict( env_file=provider_env_file(), env_file_encoding="utf-8", env_prefix="PAYMENT_SEVERPAY_", extra="ignore", ) WEBAPP_LABEL_RU: Optional[str] = None WEBAPP_LABEL_EN: Optional[str] = None WEBAPP_ICON: Optional[str] = None TELEGRAM_LABEL_RU: Optional[str] = None TELEGRAM_LABEL_EN: Optional[str] = None TELEGRAM_EMOJI: Optional[str] = None class SeverPayService(HttpClientMixin): def __init__( self, *, bot: Bot, settings: Settings, config: SeverPayConfig, i18n: JsonI18n, async_session_factory: sessionmaker, subscription_service: SubscriptionService, referral_service: ReferralService, default_return_url: str, ): self.bot = bot self.settings = settings self.config = config self.i18n = i18n self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service self._default_return_url = default_return_url self._init_http_client(total_timeout=15) if not self.configured: logging.warning( "SeverPayService initialized but not fully configured. Payments disabled." ) @property def configured(self) -> bool: return bool(provider_runtime_enabled(self.config) and self.mid and self.token) @property def base_url(self) -> str: return (self.config.BASE_URL or "https://severpay.io/api/merchant").rstrip("/") @property def mid(self): return self.config.MID @property def token(self) -> str: return self.config.TOKEN or "" @property def return_url(self) -> str: return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" @property def lifetime_minutes(self): return self.config.LIFETIME_MINUTES @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"} currency_code = normalize_payment_currency_code( currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" ) supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) if supported and currency_code not in supported: return False, { "message": "unsupported_currency", "currency": currency_code, "supported_currencies": list(supported), } session = await self._get_session() url = f"{self.base_url}/payin/create" 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) sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) payment_months = payment_units_for_activation(payment, sale_mode) if status == "success": if payment.status == "succeeded": logging.info( "SeverPay webhook: payment %s already succeeded.", payment.payment_id, ) return web.json_response({"status": True}) 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 SPEC.is_available_to_user( settings, user_id=callback.from_user.id, require_configured=False, ): await notify_service_unavailable(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 parts, hwid_quote = await quote_hwid_callback_parts( session=session, user_id=callback.from_user.id, parts=parts, subscription_service=severpay_service.subscription_service, currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) return currency_code = default_payment_currency_code_for_settings(settings) 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, hwid_quote=hwid_quote, ) 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: bundle = ctx.config_for("severpay_service") config = ( bundle.config if bundle and isinstance(bundle.config, SeverPayConfig) else SeverPayConfig() ) return SeverPayService( bot=ctx.bot, settings=ctx.settings, config=config, 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 = ctx.currency or 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", ) _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, type=type_, label=label, description=description, placeholder=placeholder, subsection="SeverPay", target="presentation", attr=attr, ) for key, type_, label, description, placeholder, attr in ( ( "PAYMENT_SEVERPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)", "Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU", ), ( "PAYMENT_SEVERPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)", "Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN", ), ( "PAYMENT_SEVERPAY_WEBAPP_ICON", "icon", "WebApp button icon", "Lucide icon name rendered inside the Web App payment method button.", "CreditCard", "WEBAPP_ICON", ), ( "PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)", "Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU", ), ( "PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)", "Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN", ), ( "PAYMENT_SEVERPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji", "Emoji prepended to the Telegram bot payment button when customized.", "💳", "TELEGRAM_EMOJI", ), ) ) _CONFIG_MANIFEST = ( ProviderManifestField( "SEVERPAY_ENABLED", "bool", "Включена", subsection="SeverPay", attr="ENABLED" ), ProviderManifestField("SEVERPAY_MID", "int", "MID", subsection="SeverPay", attr="MID"), ProviderManifestField( "SEVERPAY_TOKEN", "string", "Token", subsection="SeverPay", secret=True, attr="TOKEN" ), ProviderManifestField( "SEVERPAY_BASE_URL", "url", "Base URL", placeholder="https://severpay.io/api/merchant", subsection="SeverPay", attr="BASE_URL", ), ProviderManifestField( "SEVERPAY_RETURN_URL", "url", "Return URL", subsection="SeverPay", attr="RETURN_URL" ), ProviderManifestField( "SEVERPAY_LIFETIME_MINUTES", "int", "Payment link lifetime (minutes)", description="30..4320; leave empty for the SeverPay default.", subsection="SeverPay", min=30, max=4320, attr="LIFETIME_MINUTES", ), ProviderManifestField( "SEVERPAY_SUPPORTED_CURRENCIES", "string", "Supported currencies", description=( "Comma-separated currencies enabled for your SeverPay merchant. " "The public PayIn docs show USD examples but do not publish a fixed global list." ), placeholder="RUB,USD", subsection="SeverPay", attr="SUPPORTED_CURRENCIES", ), ) 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 config: bool(getattr(config, "ENABLED", False)), service_key="severpay_service", callback_prefix="pay_severpay", router=router, create_service=create_service, webhook_path=lambda source: "/webhook/severpay", webhook_route=severpay_webhook_route, create_webapp_payment=create_webapp_payment, config_class=SeverPayConfig, presentation_class=SeverPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"), currency_support_note="SeverPay PayIn requires a currency; keep this list aligned with your merchant account.", currency_support_url="https://docs.severpay.io/ru/payin/create", )