diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index fa363100..b5c5980a 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -2,22 +2,21 @@ from __future__ import annotations -import json from datetime import datetime from importlib import import_module -from typing import Any, Dict, Optional +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.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class CloudPaymentsPaymentMixin: @@ -30,10 +29,10 @@ class CloudPaymentsPaymentMixin: amount_kopeks: int, description: str, *, - telegram_id: int, - language: Optional[str] = None, - email: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + 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. @@ -49,13 +48,13 @@ class CloudPaymentsPaymentMixin: 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") + 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", + 'Сумма CloudPayments меньше минимальной: %s < %s', amount_kopeks, settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, ) @@ -63,36 +62,37 @@ class CloudPaymentsPaymentMixin: if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма CloudPayments больше максимальной: %s > %s", + 'Сумма CloudPayments больше максимальной: %s > %s', amount_kopeks, settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, ) return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - # Generate unique invoice ID - invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id) + # 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) + logger.error('Ошибка создания CloudPayments платежа: %s', error) return None except Exception as error: - logger.exception("Непредвиденная ошибка при создании CloudPayments платежа: %s", error) + logger.exception('Непредвиденная ошибка при создании CloudPayments платежа: %s', error) return None metadata = { - "language": language or settings.DEFAULT_LANGUAGE, - "telegram_id": telegram_id, + 'language': language or settings.DEFAULT_LANGUAGE, + 'telegram_id': telegram_id, } # Create local payment record @@ -108,30 +108,26 @@ class CloudPaymentsPaymentMixin: ) if not local_payment: - logger.error("Не удалось создать локальную запись CloudPayments платежа") + logger.error('Не удалось создать локальную запись CloudPayments платежа') return None - # CRITICAL: Commit immediately so webhook can find the payment - # (webhook runs in a different database session) - await db.commit() - logger.info( - "Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s", + 'Создан 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, + '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], + webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Pay webhook (successful payment). @@ -143,43 +139,44 @@ class CloudPaymentsPaymentMixin: 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) + 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) + 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") + logger.error('CloudPayments webhook без invoice_id') return False - payment_module = import_module("app.services.payment_service") + 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, создаём новый", + 'CloudPayments платёж не найден: invoice=%s, создаём новый', invoice_id, ) - # Try to extract telegram_id from account_id + # Try to extract user_id from account_id (we now use user_id as AccountId) try: - telegram_id = int(account_id) if account_id else None + user_id = int(account_id) if account_id else None except ValueError: - telegram_id = None + user_id = None - if not telegram_id: - logger.error("Не удалось определить telegram_id из account_id: %s", account_id) + if not user_id: + logger.error('Не удалось определить user_id из account_id: %s', account_id) return False - # Get user by telegram_id - from app.database.crud.user import get_user_by_telegram_id - user = await get_user_by_telegram_id(db, telegram_id) + # 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("Пользователь не найден: telegram_id=%s", telegram_id) + logger.error('Пользователь не найден: id=%s', user_id) return False # Create payment record @@ -193,51 +190,49 @@ class CloudPaymentsPaymentMixin: ) if not payment: - logger.error("Не удалось создать запись платежа") + logger.error('Не удалось создать запись платежа') return False # Check if already processed if payment.is_paid: - logger.info("CloudPayments платёж уже обработан: invoice=%s", invoice_id) + logger.info('CloudPayments платёж уже обработан: invoice=%s', invoice_id) return True - # Note: Check notifications are now filtered at webhook handler level - # by checking for presence of Reason or AuthCode fields. - # This handler only receives Pay notifications (confirmed payments). - # Update payment record payment.transaction_id_cp = transaction_id_cp - payment.status = "completed" + 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.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 get_user_by_id, add_user_balance + 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) + logger.error('Пользователь не найден: id=%s', payment.user_id) return False - # Add balance (don't create transaction here - we create it explicitly below with external_id) - await add_user_balance(db, user, amount_kopeks, create_transaction=False) + # Add balance + await add_user_balance(db, user.id, amount_kopeks) # Create transaction record from app.database.crud.transaction import create_transaction + transaction = await create_transaction( db=db, user_id=user.id, - type=TransactionType.DEPOSIT, + type_=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION, payment_method=PaymentMethod.CLOUDPAYMENTS, @@ -248,11 +243,12 @@ class CloudPaymentsPaymentMixin: 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", + 'CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s', invoice_id, amount_kopeks / 100, - user.telegram_id, + user_id_display, ) # Send notification to user @@ -263,33 +259,31 @@ class CloudPaymentsPaymentMixin: transaction=transaction, ) except Exception as error: - logger.exception("Ошибка отправки уведомления CloudPayments: %s", 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) - ) + 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) + 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 + db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks ) except Exception as error: - logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error) + logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error) return True async def process_cloudpayments_fail_webhook( self, db: AsyncSession, - webhook_data: Dict[str, Any], + webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Fail webhook (failed payment). @@ -301,82 +295,51 @@ class CloudPaymentsPaymentMixin: 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", "") + 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") + logger.warning('CloudPayments fail webhook без invoice_id') return True - payment_module = import_module("app.services.payment_service") + 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: - # CRITICAL: If payment was already credited, reverse the balance - if payment.is_paid: - logger.warning( - "CloudPayments: получен Declined для уже оплаченного платежа! " - "invoice=%s, amount=%s, reversing balance", - invoice_id, - payment.amount_kopeks, - ) - - # Get user and subtract balance - from app.database.crud.user import get_user_by_id, subtract_user_balance - user = await get_user_by_id(db, payment.user_id) - - if user: - await subtract_user_balance( - db, user, payment.amount_kopeks, - f"Возврат: платёж CloudPayments отклонён ({reason})" - ) - - # Create reversal transaction - from app.database.crud.transaction import create_transaction - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.WITHDRAWAL, - amount_kopeks=payment.amount_kopeks, - description=f"Возврат: платёж CloudPayments отклонён ({reason})", - payment_method=PaymentMethod.CLOUDPAYMENTS, - external_id=f"reversal_{invoice_id}", - is_completed=True, - ) - - logger.info( - "CloudPayments: баланс возвращён для пользователя %s, сумма %s₽", - user.telegram_id, - payment.amount_kopeks / 100, - ) - - payment.status = "failed" - payment.is_paid = False + payment.status = 'failed' payment.callback_payload = webhook_data await db.commit() logger.info( - "CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)", + 'CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)', invoice_id, reason, reason_code, ) - # Notify user about failed payment + # Notify user about failed payment (account_id now contains user_id, not telegram_id) try: - telegram_id = int(account_id) if account_id else None - if telegram_id: - await self._send_cloudpayments_fail_notification( - telegram_id=telegram_id, - message=card_holder_message, - ) + 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) + logger.exception('Ошибка отправки уведомления о неуспешном платеже: %s', error) return True @@ -387,12 +350,17 @@ class CloudPaymentsPaymentMixin: transaction: Any, ) -> None: """Send success notification to user via Telegram.""" + from app.bot import bot from app.localization.texts import get_texts - bot = getattr(self, "bot", None) if not bot: return + # 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) @@ -402,29 +370,29 @@ class CloudPaymentsPaymentMixin: new_balance = user.balance_kopeks / 100 message = texts.t( - "PAYMENT_SUCCESS_CLOUDPAYMENTS", - "✅ Оплата получена!\n\n" - "💰 Сумма: {amount}₽\n" - "💳 Способ: CloudPayments\n" - "💵 Баланс: {balance}₽\n\n" - "Спасибо за пополнение!", + 'PAYMENT_SUCCESS_CLOUDPAYMENTS', + '✅ Оплата получена!\n\n' + '💰 Сумма: {amount}₽\n' + '💳 Способ: CloudPayments\n' + '💵 Баланс: {balance}₽\n\n' + 'Спасибо за пополнение!', ).format( - amount=f"{amount_rub:.2f}", - balance=f"{new_balance:.2f}", + amount=f'{amount_rub:.2f}', + balance=f'{new_balance:.2f}', ) if referrer_info: - message += f"\n\n{referrer_info}" + message += f'\n\n{referrer_info}' try: await bot.send_message( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.warning("Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю %s: %s', user.telegram_id, error) async def _send_cloudpayments_fail_notification( self, @@ -432,26 +400,27 @@ class CloudPaymentsPaymentMixin: message: str, ) -> None: """Send failure notification to user via Telegram.""" - bot = getattr(self, "bot", None) + from app.bot import bot + if not bot: return - text = f"❌ Оплата не прошла\n\n{message}" + text = f'❌ Оплата не прошла\n\n{message}' try: await bot.send_message( chat_id=telegram_id, text=text, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: - logger.warning("Не удалось отправить уведомление пользователю %s: %s", telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю %s: %s', telegram_id, error) async def get_cloudpayments_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Check CloudPayments payment status via API. @@ -462,69 +431,69 @@ class CloudPaymentsPaymentMixin: Returns: Dict with payment info or None if not found """ - payment_module = import_module("app.services.payment_service") + 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) + 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"} + 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} + 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"): + if not api_response.get('Success'): logger.debug( - "CloudPayments API: payment not found or error for invoice=%s", + 'CloudPayments API: payment not found or error for invoice=%s', payment.invoice_id, ) - return {"payment": payment, "status": payment.status} + return {'payment': payment, 'status': payment.status} - model = api_response.get("Model", {}) - api_status = model.get("Status", "") - transaction_id_cp = model.get("TransactionId") + 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: + 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, + '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" + 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} + return {'payment': payment, 'status': payment.status} except Exception as error: logger.error( - "Error checking CloudPayments payment status: id=%s, error=%s", + 'Error checking CloudPayments payment status: id=%s, error=%s', local_payment_id, error, ) - return {"payment": payment, "status": payment.status} + return {'payment': payment, 'status': payment.status}