Files
remnawave-minishop/backend/bot/payment_providers/severpay.py
T

635 lines
22 KiB
Python

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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
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,
post_json_request,
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
@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"}
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 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
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:
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 = 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",
),
)
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,
)