refactor: payments providers
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,377 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiocryptopay import AioCryptoPay, Networks
|
||||
from aiocryptopay.models.update import Update
|
||||
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 (
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str],
|
||||
network: str,
|
||||
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.token = token
|
||||
if token:
|
||||
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
|
||||
self.client = AioCryptoPay(token=token, network=net)
|
||||
self.client.register_pay_handler(self._invoice_paid_handler)
|
||||
self.configured = True
|
||||
else:
|
||||
logging.warning("CryptoPay token not provided. CryptoPay disabled")
|
||||
self.client = None
|
||||
self.configured = False
|
||||
|
||||
async def close(self):
|
||||
if self.client:
|
||||
try:
|
||||
await self.client.close()
|
||||
logging.info("CryptoPay client session closed.")
|
||||
except Exception as e:
|
||||
logging.warning("Failed to close CryptoPay client: %s", e)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": (
|
||||
int(months) if sale_base == "subscription" else None
|
||||
),
|
||||
"provider": "cryptopay",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
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 is_traffic else None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
amount=amount,
|
||||
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
|
||||
fiat=self.settings.CRYPTOPAY_ASSET
|
||||
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat"
|
||||
else None,
|
||||
asset=self.settings.CRYPTOPAY_ASSET
|
||||
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto"
|
||||
else None,
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(invoice.invoice_id),
|
||||
str(invoice.status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to update cryptopay payment record %s.",
|
||||
payment_record.payment_id,
|
||||
)
|
||||
return None
|
||||
if url_kind == "web":
|
||||
return (
|
||||
getattr(invoice, "web_app_invoice_url", None)
|
||||
or getattr(invoice, "mini_app_invoice_url", None)
|
||||
or invoice.bot_invoice_url
|
||||
)
|
||||
return invoice.bot_invoice_url
|
||||
except Exception:
|
||||
logging.exception("CryptoPay invoice creation failed.")
|
||||
return None
|
||||
|
||||
async def _invoice_paid_handler(self, update: Update, app: web.Application):
|
||||
invoice = update.payload
|
||||
if not invoice.payload:
|
||||
logging.warning("CryptoPay webhook without payload")
|
||||
return
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = float(meta.get("subscription_months") or 0)
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
sale_mode = meta.get("sale_mode") or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
||||
except Exception:
|
||||
logging.exception("Failed to parse CryptoPay payload.")
|
||||
return
|
||||
|
||||
async_session_factory: sessionmaker = app["async_session_factory"]
|
||||
bot: Bot = app["bot"]
|
||||
settings: Settings = app["settings"]
|
||||
i18n: JsonI18n = app["i18n"]
|
||||
subscription_service: SubscriptionService = app["subscription_service"]
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_db_id,
|
||||
str(invoice.invoice_id),
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to mark CryptoPay invoice %s as succeeded.",
|
||||
payment_db_id,
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
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=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",
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||
if not self.token:
|
||||
return False
|
||||
|
||||
expected_signature = hmac.new(
|
||||
hashlib.sha256(self.token.encode("utf-8")).digest(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_signature, signature or ""):
|
||||
logger.error("CryptoPay signature mismatch")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured or not self.client:
|
||||
return web.Response(status=503, text="cryptopay_disabled")
|
||||
raw_body = await request.read()
|
||||
signature = request.headers.get("crypto-pay-api-signature", "")
|
||||
if not self._validate_webhook_signature(raw_body, signature):
|
||||
return web.Response(status=401)
|
||||
return await self.client.get_updates(request)
|
||||
|
||||
|
||||
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="₿",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Cross-provider helpers.
|
||||
|
||||
Provider modules at ``bot.payment_providers.<name>`` 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",
|
||||
]
|
||||
@@ -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 ``<prefix>:<value>:<price>:<sale_mode>`` 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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="⭐",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user