From 83cb1ec82342e04a23dae44efb11ec3057bfebbd Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Dec 2025 21:01:11 +0300 Subject: [PATCH] Add files via upload --- app/services/cloudpayments_service.py | 303 +++++++++++++++++++ app/services/payment_service.py | 29 +- app/services/payment_verification_service.py | 57 ++++ app/services/system_settings_service.py | 3 + 4 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 app/services/cloudpayments_service.py diff --git a/app/services/cloudpayments_service.py b/app/services/cloudpayments_service.py new file mode 100644 index 00000000..c8d15796 --- /dev/null +++ b/app/services/cloudpayments_service.py @@ -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 + } diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 1f11f243..e6559467 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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), ) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index e5e1dfe4..ce901630 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -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 diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 20620288..cb80e199 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -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",