Add files via upload

This commit is contained in:
Egor
2025-12-25 21:01:11 +03:00
committed by GitHub
parent 40d6514dee
commit 83cb1ec823
4 changed files with 391 additions and 1 deletions

View 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
}

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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",