"""Mixin for integrating CloudPayments into the payment service.""" from __future__ import annotations from datetime import datetime from importlib import import_module from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.cloudpayments_service import CloudPaymentsAPIError from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger from app.utils.user_utils import format_referrer_info class CloudPaymentsPaymentMixin: """Encapsulates creation and webhook handling for CloudPayments.""" async def create_cloudpayments_payment( self, db: AsyncSession, user_id: int, amount_kopeks: int, description: str, *, telegram_id: int | None = None, language: str | None = None, email: str | None = None, ) -> dict[str, Any] | None: """ Create a CloudPayments payment and return payment link info. Args: db: Database session user_id: Internal user ID amount_kopeks: Payment amount in kopeks description: Payment description telegram_id: User's Telegram ID language: User's language email: User's email (optional) Returns: Dict with payment_url and invoice_id, or None on error """ if not getattr(self, 'cloudpayments_service', None): logger.error('CloudPayments service is not initialised') return None if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: logger.warning( 'Сумма CloudPayments меньше минимальной: %s < %s', amount_kopeks, settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: logger.warning( 'Сумма CloudPayments больше максимальной: %s > %s', amount_kopeks, settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, ) return None payment_module = import_module('app.services.payment_service') # Generate unique invoice ID (use user_id for uniqueness, works for email-only users too) invoice_id = self.cloudpayments_service.generate_invoice_id(user_id) try: # Create payment order via CloudPayments API payment_url = await self.cloudpayments_service.generate_payment_link( telegram_id=telegram_id, user_id=user_id, amount_kopeks=amount_kopeks, invoice_id=invoice_id, description=description, email=email, ) except CloudPaymentsAPIError as error: logger.error('Ошибка создания CloudPayments платежа: %s', error) self._schedule_error_notification(error, f'CloudPayments payment creation API error: user_id={user_id}') return None except Exception as error: logger.exception('Непредвиденная ошибка при создании CloudPayments платежа: %s', error) self._schedule_error_notification(error, f'CloudPayments payment creation exception: user_id={user_id}') return None metadata = { 'language': language or settings.DEFAULT_LANGUAGE, 'telegram_id': telegram_id, } # Create local payment record local_payment = await payment_module.create_cloudpayments_payment( db=db, user_id=user_id, invoice_id=invoice_id, amount_kopeks=amount_kopeks, description=description, payment_url=payment_url, metadata=metadata, test_mode=settings.CLOUDPAYMENTS_TEST_MODE, ) if not local_payment: logger.error('Не удалось создать локальную запись CloudPayments платежа') error = ValueError('Failed to create local CloudPayments payment record') self._schedule_error_notification( error, f'CloudPayments payment creation error: user_id={user_id}, invoice_id={invoice_id}' ) return None logger.info( 'Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s', invoice_id, amount_kopeks / 100, user_id, ) return { 'payment_url': payment_url, 'invoice_id': invoice_id, 'payment_id': local_payment.id, } async def process_cloudpayments_pay_webhook( self, db: AsyncSession, webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Pay webhook (successful payment). Args: db: Database session webhook_data: Parsed webhook data Returns: True if payment was processed successfully """ invoice_id = webhook_data.get('invoice_id') transaction_id_cp = webhook_data.get('transaction_id') amount = webhook_data.get('amount', 0) amount_kopeks = int(amount * 100) account_id = webhook_data.get('account_id', '') token = webhook_data.get('token') test_mode = webhook_data.get('test_mode', False) if not invoice_id: logger.error('CloudPayments webhook без invoice_id') error = ValueError('CloudPayments webhook missing invoice_id') self._schedule_error_notification(error, 'CloudPayments webhook error: missing invoice_id') return False payment_module = import_module('app.services.payment_service') # Find existing payment record payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id) if not payment: logger.warning( 'CloudPayments платёж не найден: invoice=%s, создаём новый', invoice_id, ) # Try to extract user_id from account_id (we now use user_id as AccountId) try: user_id = int(account_id) if account_id else None except ValueError: user_id = None if not user_id: logger.error('Не удалось определить user_id из account_id: %s', account_id) error = ValueError(f'CloudPayments cannot determine user_id from account_id: {account_id}') self._schedule_error_notification( error, f'CloudPayments webhook error: cannot determine user, invoice_id={invoice_id}' ) return False # Get user by ID from app.database.crud.user import get_user_by_id user = await get_user_by_id(db, user_id) if not user: logger.error('Пользователь не найден: id=%s', user_id) error = ValueError(f'User not found: {user_id}') self._schedule_error_notification( error, f'CloudPayments webhook error: user not found, invoice_id={invoice_id}' ) return False # Create payment record payment = await payment_module.create_cloudpayments_payment( db=db, user_id=user.id, invoice_id=invoice_id, amount_kopeks=amount_kopeks, description=settings.CLOUDPAYMENTS_DESCRIPTION, test_mode=test_mode, ) if not payment: logger.error('Не удалось создать запись платежа') error = ValueError('Failed to create CloudPayments payment record from webhook') self._schedule_error_notification( error, f'CloudPayments webhook error: failed to create payment, invoice_id={invoice_id}' ) return False # Check if already processed if payment.is_paid: logger.info('CloudPayments платёж уже обработан: invoice=%s', invoice_id) return True # Update payment record payment.transaction_id_cp = transaction_id_cp payment.status = 'completed' payment.is_paid = True payment.paid_at = datetime.utcnow() payment.token = token payment.card_first_six = webhook_data.get('card_first_six') payment.card_last_four = webhook_data.get('card_last_four') payment.card_type = webhook_data.get('card_type') payment.card_exp_date = webhook_data.get('card_exp_date') payment.email = webhook_data.get('email') payment.test_mode = test_mode payment.callback_payload = webhook_data await db.flush() # Get user from app.database.crud.user import add_user_balance, get_user_by_id user = await get_user_by_id(db, payment.user_id) if not user: logger.error('Пользователь не найден: id=%s', payment.user_id) error = ValueError(f'User not found: {payment.user_id}') self._schedule_error_notification( error, f'CloudPayments finalize error: user not found, invoice_id={invoice_id}' ) return False # Add balance (без автоматической транзакции - создадим ниже с external_id) await add_user_balance(db, user, amount_kopeks, create_transaction=False) # Create transaction record from app.database.crud.transaction import create_transaction transaction = await create_transaction( db=db, user_id=user.id, type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION, payment_method=PaymentMethod.CLOUDPAYMENTS, external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id, is_completed=True, ) payment.transaction_id = transaction.id await db.commit() user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( 'CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s', invoice_id, amount_kopeks / 100, user_id_display, ) # Send notification to user try: await self._send_cloudpayments_success_notification( user=user, amount_kopeks=amount_kopeks, transaction=transaction, ) except Exception as error: logger.exception('Ошибка отправки уведомления CloudPayments: %s', error) # Auto-purchase if enabled auto_purchase_success = False try: auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None)) except Exception as error: logger.exception('Ошибка автопокупки после CloudPayments: %s', error) # Умная автоактивация если автопокупка не сработала if not auto_purchase_success: try: # Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений await auto_activate_subscription_after_topup( db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks ) except Exception as error: logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error) return True async def process_cloudpayments_fail_webhook( self, db: AsyncSession, webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Fail webhook (failed payment). Args: db: Database session webhook_data: Parsed webhook data Returns: True if processed successfully """ invoice_id = webhook_data.get('invoice_id') reason = webhook_data.get('reason', 'Unknown') reason_code = webhook_data.get('reason_code') card_holder_message = webhook_data.get('card_holder_message', reason) account_id = webhook_data.get('account_id', '') if not invoice_id: logger.warning('CloudPayments fail webhook без invoice_id') return True payment_module = import_module('app.services.payment_service') # Find payment record payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id) if payment: payment.status = 'failed' payment.callback_payload = webhook_data await db.commit() logger.info( 'CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)', invoice_id, reason, reason_code, ) # Notify user about failed payment (account_id now contains user_id, not telegram_id) try: user_id = int(account_id) if account_id else None if user_id: from app.database.crud.user import get_user_by_id # Need a new session for this query since we're outside the main flow from app.database.session import async_session_factory async with async_session_factory() as session: user = await get_user_by_id(session, user_id) if user and user.telegram_id: await self._send_cloudpayments_fail_notification( telegram_id=user.telegram_id, message=card_holder_message, ) except Exception as error: logger.exception('Ошибка отправки уведомления о неуспешном платеже: %s', error) return True async def _send_cloudpayments_success_notification( self, user: Any, amount_kopeks: int, transaction: Any, ) -> None: """Send success notification to user via Telegram.""" from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from app.config import settings from app.localization.texts import get_texts bot = Bot( token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) # Skip email-only users (no telegram_id) if not user.telegram_id: logger.debug('Skipping CloudPayments notification for email-only user %s', user.id) return texts = get_texts(user.language) keyboard = await self.build_topup_success_keyboard(user) referrer_info = format_referrer_info(user) amount_rub = amount_kopeks / 100 new_balance = user.balance_kopeks / 100 message = texts.t( 'PAYMENT_SUCCESS_CLOUDPAYMENTS', '✅ Оплата получена!\n\n' '💰 Сумма: {amount}₽\n' '💳 Способ: CloudPayments\n' '💵 Баланс: {balance}₽\n\n' 'Спасибо за пополнение!', ).format( amount=f'{amount_rub:.2f}', balance=f'{new_balance:.2f}', ) if referrer_info: message += f'\n\n{referrer_info}' try: await bot.send_message( chat_id=user.telegram_id, text=message, parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: logger.warning('Не удалось отправить уведомление пользователю %s: %s', user.telegram_id, error) async def _send_cloudpayments_fail_notification( self, telegram_id: int, message: str, ) -> None: """Send failure notification to user via Telegram.""" from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from app.config import settings bot = Bot( token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) text = f'❌ Оплата не прошла\n\n{message}' try: await bot.send_message( chat_id=telegram_id, text=text, parse_mode='HTML', ) except Exception as error: logger.warning('Не удалось отправить уведомление пользователю %s: %s', telegram_id, error) async def get_cloudpayments_payment_status( self, db: AsyncSession, local_payment_id: int, ) -> dict[str, Any] | None: """ Check CloudPayments payment status via API. Args: db: Database session local_payment_id: Internal payment ID Returns: Dict with payment info or None if not found """ payment_module = import_module('app.services.payment_service') # Get local payment record payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id) if not payment: logger.warning('CloudPayments payment not found: id=%s', local_payment_id) return None # If already paid, return current state if payment.is_paid: return {'payment': payment, 'status': 'completed'} # Check with CloudPayments API if not getattr(self, 'cloudpayments_service', None): logger.warning('CloudPayments service not initialized') return {'payment': payment, 'status': payment.status} try: # Try to find payment by invoice_id api_response = await self.cloudpayments_service.find_payment(payment.invoice_id) if not api_response.get('Success'): logger.debug( 'CloudPayments API: payment not found or error for invoice=%s', payment.invoice_id, ) return {'payment': payment, 'status': payment.status} model = api_response.get('Model', {}) api_status = model.get('Status', '') transaction_id_cp = model.get('TransactionId') # Update local record if status changed if api_status == 'Completed' and not payment.is_paid: # Payment completed - process it webhook_data = { 'invoice_id': payment.invoice_id, 'transaction_id': transaction_id_cp, 'amount': model.get('Amount', 0), 'account_id': model.get('AccountId', ''), 'token': model.get('Token'), 'card_first_six': model.get('CardFirstSix'), 'card_last_four': model.get('CardLastFour'), 'card_type': model.get('CardType'), 'card_exp_date': model.get('CardExpDate'), 'email': model.get('Email'), 'test_mode': model.get('TestMode', False), 'status': api_status, } await self.process_cloudpayments_pay_webhook(db, webhook_data) await db.refresh(payment) elif api_status in ('Declined', 'Cancelled') and payment.status not in ('failed', 'cancelled'): payment.status = 'failed' await db.flush() await db.refresh(payment) return {'payment': payment, 'status': payment.status} except Exception as error: logger.error( 'Error checking CloudPayments payment status: id=%s, error=%s', local_payment_id, error, ) return {'payment': payment, 'status': payment.status}