mirror of
https://github.com/FerraSoft/bottohelp.git
synced 2026-08-06 21:55:03 +00:00
Подготовка к релизу
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Сервис для обработки донатов через различные платежные системы.
|
||||
"""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user