mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-15 08:30:29 +00:00
Add files via upload
This commit is contained in:
303
app/services/cloudpayments_service.py
Normal file
303
app/services/cloudpayments_service.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""High level service for interacting with CloudPayments API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudPaymentsAPIError(RuntimeError):
|
||||
"""Raised when the CloudPayments API returns an error response."""
|
||||
|
||||
def __init__(self, message: str, reason_code: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class CloudPaymentsService:
|
||||
"""Wrapper around the CloudPayments REST API for balance top-ups."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
public_id: Optional[str] = None,
|
||||
api_secret: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
widget_url: Optional[str] = None,
|
||||
) -> None:
|
||||
self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID
|
||||
self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET
|
||||
self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/")
|
||||
self.widget_url = (widget_url or settings.CLOUDPAYMENTS_WIDGET_URL).rstrip("/")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
settings.is_cloudpayments_enabled()
|
||||
and self.public_id
|
||||
and self.api_secret
|
||||
)
|
||||
|
||||
def _get_auth_header(self) -> str:
|
||||
"""Generate Basic Auth header for CloudPayments API."""
|
||||
if not self.public_id or not self.api_secret:
|
||||
raise CloudPaymentsAPIError("CloudPayments credentials not configured")
|
||||
credentials = f"{self.public_id}:{self.api_secret}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": self._get_auth_header(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a request to CloudPayments API."""
|
||||
if not self.is_configured:
|
||||
raise CloudPaymentsAPIError("CloudPayments service is not configured")
|
||||
|
||||
url = f"{self.api_url}/{path.lstrip('/')}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"CloudPayments API error %s: %s", response.status_code, data
|
||||
)
|
||||
raise CloudPaymentsAPIError(
|
||||
f"CloudPayments API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
except httpx.RequestError as error:
|
||||
logger.error("Error communicating with CloudPayments API: %s", error)
|
||||
raise CloudPaymentsAPIError(
|
||||
"Failed to communicate with CloudPayments API"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def _amount_from_kopeks(amount_kopeks: int) -> float:
|
||||
"""Convert kopeks to rubles."""
|
||||
return amount_kopeks / 100
|
||||
|
||||
@staticmethod
|
||||
def _amount_to_kopeks(amount: float) -> int:
|
||||
"""Convert rubles to kopeks."""
|
||||
return int(amount * 100)
|
||||
|
||||
def generate_payment_link(
|
||||
self,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
invoice_id: str,
|
||||
description: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a payment widget URL for CloudPayments.
|
||||
|
||||
Args:
|
||||
telegram_id: User's Telegram ID (will be used as AccountId)
|
||||
amount_kopeks: Amount in kopeks
|
||||
invoice_id: Unique invoice ID for this payment
|
||||
description: Payment description
|
||||
email: User's email (optional)
|
||||
|
||||
Returns:
|
||||
URL to CloudPayments payment widget
|
||||
"""
|
||||
if not self.public_id:
|
||||
raise CloudPaymentsAPIError("CloudPayments public_id not configured")
|
||||
|
||||
amount = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
params = {
|
||||
"publicId": self.public_id,
|
||||
"description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
"amount": amount,
|
||||
"currency": settings.CLOUDPAYMENTS_CURRENCY,
|
||||
"accountId": str(telegram_id),
|
||||
"invoiceId": invoice_id,
|
||||
"skin": settings.CLOUDPAYMENTS_SKIN,
|
||||
}
|
||||
|
||||
if settings.CLOUDPAYMENTS_REQUIRE_EMAIL:
|
||||
params["requireEmail"] = "true"
|
||||
|
||||
if email:
|
||||
params["email"] = email
|
||||
|
||||
# Добавляем JSON данные для webhook
|
||||
params["data"] = f'{{"telegram_id": {telegram_id}, "invoice_id": "{invoice_id}"}}'
|
||||
|
||||
return f"{self.widget_url}?{urlencode(params)}"
|
||||
|
||||
def generate_invoice_id(self, telegram_id: int) -> str:
|
||||
"""Generate unique invoice ID for a payment."""
|
||||
return f"cp_{telegram_id}_{int(time.time())}"
|
||||
|
||||
async def charge_by_token(
|
||||
self,
|
||||
token: str,
|
||||
amount_kopeks: int,
|
||||
account_id: str,
|
||||
invoice_id: str,
|
||||
description: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Charge a payment using saved card token (recurrent payment).
|
||||
|
||||
Args:
|
||||
token: Card token from previous payment
|
||||
amount_kopeks: Amount in kopeks
|
||||
account_id: User's account ID (telegram_id)
|
||||
invoice_id: Unique invoice ID
|
||||
description: Payment description
|
||||
|
||||
Returns:
|
||||
CloudPayments API response
|
||||
"""
|
||||
amount = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
payload = {
|
||||
"Amount": amount,
|
||||
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
|
||||
"AccountId": account_id,
|
||||
"Token": token,
|
||||
"InvoiceId": invoice_id,
|
||||
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
}
|
||||
|
||||
return await self._request("POST", "/payments/tokens/charge", json=payload)
|
||||
|
||||
async def get_payment(self, transaction_id: int) -> Dict[str, Any]:
|
||||
"""Get payment details by CloudPayments transaction ID."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/get",
|
||||
json={"TransactionId": transaction_id},
|
||||
)
|
||||
|
||||
async def find_payment(self, invoice_id: str) -> Dict[str, Any]:
|
||||
"""Find payment by invoice ID."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/find",
|
||||
json={"InvoiceId": invoice_id},
|
||||
)
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
transaction_id: int,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a payment (full or partial).
|
||||
|
||||
Args:
|
||||
transaction_id: CloudPayments transaction ID
|
||||
amount_kopeks: Amount to refund in kopeks (None for full refund)
|
||||
|
||||
Returns:
|
||||
CloudPayments API response
|
||||
"""
|
||||
payload: Dict[str, Any] = {"TransactionId": transaction_id}
|
||||
|
||||
if amount_kopeks is not None:
|
||||
payload["Amount"] = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
return await self._request("POST", "/payments/refund", json=payload)
|
||||
|
||||
async def void_payment(self, transaction_id: int) -> Dict[str, Any]:
|
||||
"""Cancel an authorized but not captured payment."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/void",
|
||||
json={"TransactionId": transaction_id},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: bytes, signature: str, api_secret: str) -> bool:
|
||||
"""
|
||||
Verify CloudPayments webhook signature.
|
||||
|
||||
Args:
|
||||
body: Raw request body bytes
|
||||
signature: Signature from X-Content-HMAC header
|
||||
api_secret: CloudPayments API secret
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
if not signature or not api_secret:
|
||||
return False
|
||||
|
||||
calculated = base64.b64encode(
|
||||
hmac.new(
|
||||
api_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
return hmac.compare_digest(calculated, signature)
|
||||
|
||||
@staticmethod
|
||||
def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse webhook form data into structured format.
|
||||
|
||||
Args:
|
||||
form_data: Form data from webhook request
|
||||
|
||||
Returns:
|
||||
Parsed payment data
|
||||
"""
|
||||
return {
|
||||
"transaction_id": int(form_data.get("TransactionId", 0)),
|
||||
"amount": float(form_data.get("Amount", 0)),
|
||||
"currency": form_data.get("Currency", "RUB"),
|
||||
"invoice_id": form_data.get("InvoiceId", ""),
|
||||
"account_id": form_data.get("AccountId", ""),
|
||||
"token": form_data.get("Token"),
|
||||
"card_first_six": form_data.get("CardFirstSix"),
|
||||
"card_last_four": form_data.get("CardLastFour"),
|
||||
"card_type": form_data.get("CardType"),
|
||||
"card_exp_date": form_data.get("CardExpDate"),
|
||||
"email": form_data.get("Email"),
|
||||
"status": form_data.get("Status", ""),
|
||||
"test_mode": form_data.get("TestMode") == "1" or form_data.get("TestMode") == "True",
|
||||
"reason": form_data.get("Reason"),
|
||||
"reason_code": int(form_data.get("ReasonCode", 0)) if form_data.get("ReasonCode") else None,
|
||||
"card_holder_message": form_data.get("CardHolderMessage"),
|
||||
"data": form_data.get("Data"), # JSON string with custom data
|
||||
}
|
||||
@@ -28,8 +28,10 @@ from app.services.payment import (
|
||||
YooKassaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
)
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.cloudpayments_service import CloudPaymentsService
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -263,6 +265,26 @@ async def link_heleket_payment_to_transaction(*args, **kwargs):
|
||||
return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_cloudpayments_payment(*args, **kwargs):
|
||||
cloudpayments_crud = import_module("app.database.crud.cloudpayments")
|
||||
return await cloudpayments_crud.create_cloudpayments_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_invoice_id(*args, **kwargs):
|
||||
cloudpayments_crud = import_module("app.database.crud.cloudpayments")
|
||||
return await cloudpayments_crud.get_cloudpayments_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_id(*args, **kwargs):
|
||||
cloudpayments_crud = import_module("app.database.crud.cloudpayments")
|
||||
return await cloudpayments_crud.get_cloudpayments_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_cloudpayments_payment(*args, **kwargs):
|
||||
cloudpayments_crud = import_module("app.database.crud.cloudpayments")
|
||||
return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs)
|
||||
|
||||
|
||||
class PaymentService(
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
@@ -274,6 +296,7 @@ class PaymentService(
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
CloudPaymentsPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -301,11 +324,14 @@ class PaymentService(
|
||||
PlategaService() if settings.is_platega_enabled() else None
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
self.cloudpayments_service = (
|
||||
CloudPaymentsService() if settings.is_cloudpayments_enabled() else None
|
||||
)
|
||||
self.nalogo_service = NaloGoService() if settings.is_nalogo_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s, CloudPayments=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
@@ -315,4 +341,5 @@ class PaymentService(
|
||||
bool(self.pal24_service),
|
||||
bool(self.platega_service),
|
||||
bool(self.wata_service),
|
||||
bool(self.cloudpayments_service),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
@@ -64,6 +65,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -76,6 +78,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -95,6 +98,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return "CloudPayments"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
@@ -115,6 +120,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return settings.is_heleket_enabled()
|
||||
if method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return settings.is_cloudpayments_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -348,6 +355,13 @@ def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
|
||||
return status == "active"
|
||||
|
||||
|
||||
def _is_cloudpayments_pending(payment: CloudPaymentsPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "authorized"}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ""
|
||||
match = re.search(r"_(\d+)$", payload)
|
||||
@@ -582,6 +596,31 @@ async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(CloudPaymentsPayment)
|
||||
.options(selectinload(CloudPaymentsPayment.user))
|
||||
.where(CloudPaymentsPayment.created_at >= cutoff)
|
||||
.order_by(desc(CloudPaymentsPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_cloudpayments_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -626,6 +665,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_platega_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_cloudpayments_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -752,6 +792,20 @@ async def get_payment_record(
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.CLOUDPAYMENTS:
|
||||
payment = await db.get(CloudPaymentsPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -803,6 +857,9 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.CRYPTOBOT:
|
||||
result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.CLOUDPAYMENTS:
|
||||
result = await payment_service.get_cloudpayments_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
else:
|
||||
logger.warning("Manual check requested for unsupported method %s", method)
|
||||
return None
|
||||
|
||||
@@ -83,6 +83,7 @@ class BotConfigurationService:
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"CLOUDPAYMENTS": "💳 CloudPayments",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 {platega_name}",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
@@ -138,6 +139,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
|
||||
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
@@ -310,6 +312,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
|
||||
Reference in New Issue
Block a user