mirror of
https://github.com/FerraSoft/bottohelp.git
synced 2026-08-06 21:55:03 +00:00
461 lines
19 KiB
Python
461 lines
19 KiB
Python
"""
|
||
Сервис для обработки донатов через различные платежные системы.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
from typing import Dict, List, Optional, Any, Tuple
|
||
from datetime import datetime, timedelta
|
||
from dataclasses import dataclass
|
||
|
||
from core.payment_models import (
|
||
PaymentIntent, PaymentEvent, PaymentStatus, TransactionType,
|
||
DonationResponse, PaymentValidationResult
|
||
)
|
||
from core.payment_exceptions import (
|
||
PaymentError, PaymentProviderError, PaymentValidationError,
|
||
PaymentSecurityError, PaymentDuplicateError, PaymentNotFoundError
|
||
)
|
||
from services.payment_provider import (
|
||
PaymentProvider, StripePaymentProvider, YooKassaPaymentProvider, SBPaymentProvider
|
||
)
|
||
from database.payment_repository import PaymentRepository, TransactionRepository
|
||
from services.user_service import UserService
|
||
from services.notification_service import NotificationService
|
||
|
||
|
||
@dataclass
|
||
class PaymentConfig:
|
||
"""Конфигурация платежного провайдера"""
|
||
enabled: bool
|
||
api_key: Optional[str] = None
|
||
webhook_secret: Optional[str] = None
|
||
shop_id: Optional[str] = None
|
||
secret_key: Optional[str] = None
|
||
bank_id: Optional[str] = None
|
||
|
||
|
||
class DonationService:
|
||
"""
|
||
Основной сервис для обработки донатов.
|
||
|
||
Отвечает за:
|
||
- Создание платежей через различные провайдеры
|
||
- Валидацию платежных данных
|
||
- Обработку webhook уведомлений
|
||
- Управление статусами платежей
|
||
- Интеграцию с пользователями и уведомлениями
|
||
"""
|
||
|
||
def __init__(self, payment_repo: PaymentRepository,
|
||
transaction_repo: TransactionRepository,
|
||
user_service: UserService,
|
||
notification_service: NotificationService,
|
||
payment_configs: Dict[str, PaymentConfig]):
|
||
"""
|
||
Инициализация сервиса донатов.
|
||
|
||
Args:
|
||
payment_repo: Репозиторий платежей
|
||
transaction_repo: Репозиторий транзакций
|
||
user_service: Сервис пользователей
|
||
notification_service: Сервис уведомлений
|
||
payment_configs: Конфигурации платежных провайдеров
|
||
"""
|
||
self.payment_repo = payment_repo
|
||
self.transaction_repo = transaction_repo
|
||
self.user_service = user_service
|
||
self.notification_service = notification_service
|
||
self.payment_configs = payment_configs
|
||
|
||
self.logger = logging.getLogger(__name__)
|
||
|
||
# Инициализация провайдеров
|
||
self.providers: Dict[str, PaymentProvider] = {}
|
||
self._initialize_providers()
|
||
|
||
# Инициализация rate limiter для защиты от злоупотреблений
|
||
from core.payment_security import PaymentRateLimiter
|
||
self.rate_limiter = PaymentRateLimiter(
|
||
max_payments_per_hour=10, # Максимум 10 платежей в час на пользователя
|
||
max_amount_per_hour=50000.0 # Максимум 50000 руб в час
|
||
)
|
||
|
||
# Настройки валидации
|
||
self.min_amount = 10.0
|
||
self.max_amount = 10000.0
|
||
self.allowed_currencies = ['RUB', 'USD', 'EUR']
|
||
|
||
# Настройки безопасности
|
||
self.duplicate_check_window_minutes = 30
|
||
|
||
def _initialize_providers(self):
|
||
"""Инициализация платежных провайдеров"""
|
||
# Stripe
|
||
stripe_config = self.payment_configs.get('stripe')
|
||
if stripe_config and stripe_config.enabled:
|
||
self.providers['stripe'] = StripePaymentProvider(
|
||
api_key=stripe_config.api_key,
|
||
webhook_secret=stripe_config.webhook_secret
|
||
)
|
||
|
||
# YooKassa
|
||
yookassa_config = self.payment_configs.get('yookassa')
|
||
if yookassa_config and yookassa_config.enabled:
|
||
self.providers['yookassa'] = YooKassaPaymentProvider(
|
||
shop_id=yookassa_config.shop_id,
|
||
secret_key=yookassa_config.secret_key
|
||
)
|
||
|
||
# СБП (заглушка)
|
||
sbp_config = self.payment_configs.get('sbp')
|
||
if sbp_config and sbp_config.enabled:
|
||
self.providers['sbp'] = SBPaymentProvider(
|
||
api_key=sbp_config.api_key,
|
||
bank_id=sbp_config.bank_id,
|
||
webhook_secret=sbp_config.webhook_secret
|
||
)
|
||
|
||
async def create_donation(self, user_id: int, amount: float,
|
||
provider_name: str = 'stripe',
|
||
metadata: Optional[Dict[str, Any]] = None) -> DonationResponse:
|
||
"""
|
||
Создание доната.
|
||
|
||
Args:
|
||
user_id: ID пользователя
|
||
amount: Сумма доната
|
||
provider_name: Название провайдера
|
||
metadata: Дополнительные данные
|
||
|
||
Returns:
|
||
DonationResponse: Ответ с URL оплаты и ID платежа
|
||
|
||
Raises:
|
||
PaymentValidationError: Ошибка валидации
|
||
PaymentProviderError: Ошибка провайдера
|
||
"""
|
||
try:
|
||
self.logger.info(f"Creating donation: user_id={user_id}, amount={amount}, provider={provider_name}")
|
||
|
||
# Валидация входных данных
|
||
validation_result = self._validate_donation_data(user_id, amount, provider_name)
|
||
if not validation_result.is_valid:
|
||
raise PaymentValidationError(f"Validation failed: {', '.join(validation_result.errors)}")
|
||
|
||
# Проверка rate limit
|
||
rate_limit_result = self.rate_limiter.check_rate_limit(user_id, amount)
|
||
if not rate_limit_result['allowed']:
|
||
raise PaymentValidationError(
|
||
f"Rate limit exceeded. Try again after {rate_limit_result['reset_time']}"
|
||
)
|
||
|
||
# Проверка на дубликаты
|
||
if self._check_duplicate_donation(user_id, amount):
|
||
raise PaymentDuplicateError("Duplicate donation detected")
|
||
|
||
# Получение провайдера
|
||
provider = self._get_provider(provider_name)
|
||
|
||
# Подготовка метаданных
|
||
if metadata is None:
|
||
metadata = {}
|
||
metadata.update({
|
||
'user_id': str(user_id),
|
||
'created_at': datetime.now().isoformat(),
|
||
'service': 'telegram_bot_donation'
|
||
})
|
||
|
||
# Создание платежа через провайдера
|
||
payment_intent = provider.create_payment(amount, 'RUB', user_id, metadata)
|
||
|
||
# Сохранение платежа в БД
|
||
payment_record = self.payment_repo.create_payment({
|
||
'user_id': user_id,
|
||
'amount': amount,
|
||
'currency': 'RUB',
|
||
'provider': provider_name,
|
||
'external_id': payment_intent.id,
|
||
'status': 'pending',
|
||
'metadata': metadata,
|
||
'created_at': datetime.now(),
|
||
'updated_at': datetime.now()
|
||
})
|
||
|
||
self.logger.info(f"Donation created: payment_id={payment_record['id']}, external_id={payment_intent.id}")
|
||
|
||
# Создание транзакции
|
||
self.transaction_repo.create_transaction({
|
||
'payment_id': payment_record['id'],
|
||
'type': 'payment',
|
||
'amount': amount,
|
||
'status': 'pending',
|
||
'created_at': datetime.now()
|
||
})
|
||
|
||
# Регистрация платежа в rate limiter для учета лимитов
|
||
self.rate_limiter.record_payment(user_id, amount)
|
||
|
||
return DonationResponse(
|
||
payment_url=payment_intent.url,
|
||
payment_id=payment_record['id'],
|
||
provider=provider_name,
|
||
amount=amount,
|
||
currency='RUB'
|
||
)
|
||
|
||
except (PaymentValidationError, PaymentDuplicateError, PaymentProviderError):
|
||
raise
|
||
except Exception as e:
|
||
self.logger.error(f"Unexpected error creating donation: {e}", exc_info=True)
|
||
raise PaymentError(f"Failed to create donation: {e}")
|
||
|
||
async def process_payment_webhook(self, provider_name: str, webhook_data: Dict[str, Any],
|
||
signature: str) -> bool:
|
||
"""
|
||
Обработка webhook уведомления от платежного провайдера.
|
||
|
||
Args:
|
||
provider_name: Название провайдера
|
||
webhook_data: Данные webhook
|
||
signature: Подпись для валидации
|
||
|
||
Returns:
|
||
bool: Успешно ли обработано
|
||
|
||
Raises:
|
||
PaymentSecurityError: Ошибка безопасности
|
||
PaymentProviderError: Ошибка провайдера
|
||
"""
|
||
try:
|
||
self.logger.info(f"Processing webhook: provider={provider_name}")
|
||
|
||
# Получение провайдера
|
||
provider = self._get_provider(provider_name)
|
||
|
||
# Валидация подписи
|
||
if not provider.validate_webhook(webhook_data, signature):
|
||
raise PaymentSecurityError("Invalid webhook signature")
|
||
|
||
# Обработка события платежа
|
||
payment_event = provider.process_webhook(webhook_data)
|
||
|
||
# Обработка события
|
||
await self._handle_payment_event(payment_event)
|
||
|
||
return True
|
||
|
||
except (PaymentSecurityError, PaymentProviderError):
|
||
raise
|
||
except Exception as e:
|
||
self.logger.error(f"Error processing webhook: {e}", exc_info=True)
|
||
raise PaymentProviderError(f"Webhook processing failed: {e}")
|
||
|
||
async def _handle_payment_event(self, event: PaymentEvent):
|
||
"""
|
||
Обработка события платежа.
|
||
|
||
Args:
|
||
event: Событие платежа
|
||
"""
|
||
try:
|
||
self.logger.info(f"Handling payment event: {event.type} for payment {event.payment_id}")
|
||
|
||
# Определение типа события
|
||
if event.type == 'payment.succeeded':
|
||
await self._process_successful_payment(event)
|
||
elif event.type == 'payment.failed':
|
||
await self._process_failed_payment(event)
|
||
elif event.type == 'payment.cancelled':
|
||
await self._process_cancelled_payment(event)
|
||
elif event.type == 'payment.pending':
|
||
await self._process_pending_payment(event)
|
||
else:
|
||
self.logger.warning(f"Unknown payment event type: {event.type}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error handling payment event: {e}", exc_info=True)
|
||
# Не прерываем обработку, логируем ошибку
|
||
|
||
async def _process_successful_payment(self, event: PaymentEvent):
|
||
"""Обработка успешного платежа"""
|
||
try:
|
||
# Обновление статуса платежа
|
||
success = self.payment_repo.update_payment_status_by_external_id(
|
||
event.payment_id, 'succeeded',
|
||
processed_at=datetime.now()
|
||
)
|
||
|
||
if not success:
|
||
self.logger.error(f"Failed to update payment status for {event.payment_id}")
|
||
return
|
||
|
||
# Получение данных платежа
|
||
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
|
||
if not payment:
|
||
self.logger.error(f"Payment not found: {event.payment_id}")
|
||
return
|
||
|
||
# Обновление баланса пользователя
|
||
await self._update_user_balance(payment['user_id'], event.amount)
|
||
|
||
# Обновление транзакции
|
||
transactions = self.transaction_repo.get_transactions_by_payment(payment['id'])
|
||
if transactions:
|
||
self.transaction_repo.update_transaction_status(
|
||
transactions[0]['id'], 'succeeded'
|
||
)
|
||
|
||
# Уведомление пользователя
|
||
if self.notification_service:
|
||
await self.notification_service.notify_payment_success(
|
||
payment['user_id'], event.amount
|
||
)
|
||
|
||
self.logger.info(f"Successfully processed payment: {event.payment_id}, amount: {event.amount}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error processing successful payment: {e}", exc_info=True)
|
||
|
||
async def _process_failed_payment(self, event: PaymentEvent):
|
||
"""Обработка неудачного платежа"""
|
||
try:
|
||
# Обновление статуса платежа
|
||
self.payment_repo.update_payment_status_by_external_id(
|
||
event.payment_id, 'failed',
|
||
processed_at=datetime.now(),
|
||
error_message=event.metadata.get('error', 'Payment failed')
|
||
)
|
||
|
||
# Получение данных платежа
|
||
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
|
||
if payment and self.notification_service:
|
||
await self.notification_service.notify_payment_failed(
|
||
payment['user_id'],
|
||
event.metadata.get('error', 'Unknown error')
|
||
)
|
||
|
||
self.logger.info(f"Processed failed payment: {event.payment_id}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error processing failed payment: {e}", exc_info=True)
|
||
|
||
async def _process_cancelled_payment(self, event: PaymentEvent):
|
||
"""Обработка отмененного платежа"""
|
||
try:
|
||
# Обновление статуса платежа
|
||
self.payment_repo.update_payment_status_by_external_id(
|
||
event.payment_id, 'cancelled',
|
||
processed_at=datetime.now()
|
||
)
|
||
|
||
self.logger.info(f"Processed cancelled payment: {event.payment_id}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error processing cancelled payment: {e}", exc_info=True)
|
||
|
||
async def _process_pending_payment(self, event: PaymentEvent):
|
||
"""Обработка платежа в ожидании"""
|
||
try:
|
||
# Обновление статуса платежа
|
||
self.payment_repo.update_payment_status_by_external_id(
|
||
event.payment_id, 'processing'
|
||
)
|
||
|
||
self.logger.info(f"Processed pending payment: {event.payment_id}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error processing pending payment: {e}", exc_info=True)
|
||
|
||
async def _update_user_balance(self, user_id: int, amount: float):
|
||
"""Обновление баланса пользователя после успешного платежа"""
|
||
try:
|
||
# Добавление доната в систему пользователей
|
||
await self.user_service.add_donation(user_id, amount)
|
||
|
||
self.logger.info(f"Updated user balance: user_id={user_id}, amount={amount}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error updating user balance: {e}", exc_info=True)
|
||
|
||
def _validate_donation_data(self, user_id: int, amount: float, provider_name: str) -> PaymentValidationResult:
|
||
"""Валидация данных доната"""
|
||
errors = []
|
||
warnings = []
|
||
|
||
# Проверка user_id
|
||
if not isinstance(user_id, int) or user_id <= 0:
|
||
errors.append("Invalid user ID")
|
||
|
||
# Проверка суммы
|
||
if not isinstance(amount, (int, float)) or amount <= 0:
|
||
errors.append("Amount must be positive number")
|
||
elif amount < self.min_amount:
|
||
errors.append(f"Minimum amount is {self.min_amount}")
|
||
elif amount > self.max_amount:
|
||
errors.append(f"Maximum amount is {self.max_amount}")
|
||
|
||
# Проверка провайдера
|
||
if provider_name not in self.providers:
|
||
errors.append(f"Unsupported provider: {provider_name}")
|
||
elif not self.payment_configs.get(provider_name, PaymentConfig(False)).enabled:
|
||
errors.append(f"Provider {provider_name} is disabled")
|
||
|
||
return PaymentValidationResult(
|
||
is_valid=len(errors) == 0,
|
||
errors=errors,
|
||
warnings=warnings
|
||
)
|
||
|
||
def _check_duplicate_donation(self, user_id: int, amount: float) -> bool:
|
||
"""Проверка на дубликат доната"""
|
||
return self.payment_repo.check_duplicate_payment(
|
||
"", amount, user_id, self.duplicate_check_window_minutes
|
||
)
|
||
|
||
def _get_provider(self, provider_name: str) -> PaymentProvider:
|
||
"""Получение провайдера по имени"""
|
||
if provider_name not in self.providers:
|
||
raise PaymentProviderError(f"Provider {provider_name} not found")
|
||
return self.providers[provider_name]
|
||
|
||
def get_payment_status(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||
"""Получение статуса платежа"""
|
||
return self.payment_repo.get_payment_by_id(payment_id)
|
||
|
||
def get_user_payments(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||
"""Получение платежей пользователя"""
|
||
return self.payment_repo.get_payments_by_user(user_id, limit)
|
||
|
||
def get_payment_statistics(self, date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None) -> Dict[str, Any]:
|
||
"""Получение статистики платежей"""
|
||
return self.payment_repo.get_payment_statistics(date_from, date_to)
|
||
|
||
def cancel_payment(self, payment_id: int, user_id: int) -> bool:
|
||
"""Отмена платежа"""
|
||
try:
|
||
# Получение данных платежа
|
||
payment = self.payment_repo.get_payment_by_id(payment_id)
|
||
if not payment:
|
||
raise PaymentNotFoundError(f"Payment {payment_id} not found")
|
||
|
||
if payment['user_id'] != user_id:
|
||
raise PaymentSecurityError("Unauthorized payment cancellation")
|
||
|
||
if payment['status'] not in ['pending', 'processing']:
|
||
return False # Нельзя отменить завершенный платеж
|
||
|
||
# Получение провайдера и отмена платежа
|
||
provider = self._get_provider(payment['provider'])
|
||
success = provider.cancel_payment(payment['external_id'])
|
||
|
||
if success:
|
||
self.payment_repo.update_payment_status(payment_id, 'cancelled')
|
||
return True
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Error cancelling payment: {e}", exc_info=True)
|
||
return False |