diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 0195eb2f..79a4101b 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -1,8 +1,9 @@ """Mixin с логикой обработки платежей CryptoBot.""" from __future__ import annotations - import logging +import math +from dataclasses import dataclass from datetime import datetime from importlib import import_module from typing import Any, Dict, Optional @@ -10,6 +11,7 @@ from typing import Any, Dict, Optional from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.database import AsyncSessionLocal from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, @@ -20,6 +22,33 @@ from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) +@dataclass(slots=True) +class _AdminNotificationContext: + user_id: int + transaction_id: int + old_balance: int + topup_status: str + referrer_info: str + + +@dataclass(slots=True) +class _UserNotificationPayload: + telegram_id: int + text: str + parse_mode: Optional[str] + reply_markup: Any + amount_rubles: float + asset: str + + +@dataclass(slots=True) +class _SavedCartNotificationPayload: + telegram_id: int + text: str + reply_markup: Any + user_id: int + + class CryptoBotPaymentMixin: """Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook.""" @@ -149,14 +178,16 @@ class CryptoBotPaymentMixin: try: amount_rubles = await currency_converter.usd_to_rub(amount_usd) - amount_kopeks = int(amount_rubles * 100) + amount_rubles_rounded = math.ceil(amount_rubles) + amount_kopeks = int(amount_rubles_rounded * 100) conversion_rate = ( amount_rubles / amount_usd if amount_usd > 0 else 0 ) logger.info( - "Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)", + "Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)", amount_usd, amount_rubles, + amount_rubles_rounded, conversion_rate, ) except Exception as error: @@ -166,7 +197,8 @@ class CryptoBotPaymentMixin: error, ) amount_rubles = amount_usd - amount_kopeks = int(amount_usd * 100) + amount_rubles_rounded = math.ceil(amount_rubles) + amount_kopeks = int(amount_rubles_rounded * 100) conversion_rate = 1.0 if amount_kopeks <= 0: @@ -185,7 +217,7 @@ class CryptoBotPaymentMixin: amount_kopeks=amount_kopeks, description=( "Пополнение через CryptoBot " - f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)" + f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)" ), payment_method=PaymentMethod.CRYPTOBOT, external_id=invoice_id, @@ -211,8 +243,6 @@ class CryptoBotPaymentMixin: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = ( "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" @@ -241,55 +271,41 @@ class CryptoBotPaymentMixin: await db.refresh(user) - if getattr(self, "bot", None): - try: - from app.services.admin_notification_service import ( - AdminNotificationService, - ) + admin_notification: Optional[_AdminNotificationContext] = None + user_notification: Optional[_UserNotificationPayload] = None + saved_cart_notification: Optional[_SavedCartNotificationPayload] = None - notification_service = AdminNotificationService(self.bot) - await notification_service.send_balance_topup_notification( - user, - transaction, - old_balance, - topup_status=topup_status, - referrer_info=referrer_info, - subscription=subscription, - promo_group=promo_group, - db=db, - ) - except Exception as error: - logger.error( - "Ошибка отправки уведомления о пополнении CryptoBot: %s", - error, - ) + bot_instance = getattr(self, "bot", None) + if bot_instance: + admin_notification = _AdminNotificationContext( + user_id=user.id, + transaction_id=transaction.id, + old_balance=old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + ) - if getattr(self, "bot", None): try: keyboard = await self.build_topup_success_keyboard(user) - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n" - f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n" - f"🆔 Транзакция: {invoice_id[:8]}...\n\n" - "Баланс пополнен автоматически!" - ), + message_text = ( + "✅ Пополнение успешно!\n\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n" + f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n" + f"🆔 Транзакция: {invoice_id[:8]}...\n\n" + "Баланс пополнен автоматически!" + ) + user_notification = _UserNotificationPayload( + telegram_id=user.telegram_id, + text=message_text, parse_mode="HTML", reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)", - user.telegram_id, - f"{amount_rubles:.2f}", - updated_payment.asset, + amount_rubles=amount_rubles_rounded, + asset=updated_payment.asset, ) except Exception as error: logger.error( - "Ошибка отправки уведомления о пополнении CryptoBot: %s", + "Ошибка подготовки уведомления о пополнении CryptoBot: %s", error, ) @@ -305,7 +321,7 @@ class CryptoBotPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=bot_instance, ) except Exception as auto_error: logger.error( @@ -318,17 +334,14 @@ class CryptoBotPaymentMixin: if auto_purchase_success: has_saved_cart = False - if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению + if has_saved_cart and bot_instance: from app.localization.texts import get_texts - + texts = get_texts(user.language) cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( total_amount=settings.format_price(payment.amount_kopeks) ) - - # Создаем клавиатуру с кнопками + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, @@ -343,22 +356,35 @@ class CryptoBotPaymentMixin: callback_data="back_to_menu" )] ]) - - await self.bot.send_message( - chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", - reply_markup=keyboard + + saved_cart_notification = _SavedCartNotificationPayload( + telegram_id=user.telegram_id, + text=( + f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" + f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " + f"Обязательно активируйте подписку отдельно!\n\n" + f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " + f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}" + ), + reply_markup=keyboard, + user_id=user.id, ) - logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", - user.id, - ) - except Exception as e: - logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + except Exception as error: + logger.error( + "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + user.id, + error, + exc_info=True, + ) + + if admin_notification: + await self._deliver_admin_topup_notification(admin_notification) + + if user_notification and bot_instance: + await self._deliver_user_topup_notification(user_notification) + + if saved_cart_notification and bot_instance: + await self._deliver_saved_cart_reminder(saved_cart_notification) return True @@ -368,6 +394,116 @@ class CryptoBotPaymentMixin: ) return False + async def _deliver_admin_topup_notification( + self, context: _AdminNotificationContext + ) -> None: + bot_instance = getattr(self, "bot", None) + if not bot_instance: + return + + try: + from app.services.admin_notification_service import AdminNotificationService + from app.database.crud.user import get_user_by_id + from app.database.crud.transaction import get_transaction_by_id + except Exception as error: + logger.error( + "Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s", + error, + exc_info=True, + ) + return + + async with AsyncSessionLocal() as session: + try: + user = await get_user_by_id(session, context.user_id) + transaction = await get_transaction_by_id(session, context.transaction_id) + except Exception as error: + logger.error( + "Ошибка загрузки данных для админ-уведомления CryptoBot: %s", + error, + exc_info=True, + ) + await session.rollback() + return + + if not user or not transaction: + logger.warning( + "Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s", + bool(user), + bool(transaction), + ) + return + + notification_service = AdminNotificationService(bot_instance) + try: + await notification_service.send_balance_topup_notification( + user, + transaction, + context.old_balance, + topup_status=context.topup_status, + referrer_info=context.referrer_info, + subscription=getattr(user, "subscription", None), + promo_group=getattr(user, "promo_group", None), + db=session, + ) + except Exception as error: + logger.error( + "Ошибка отправки админ-уведомления о пополнении CryptoBot: %s", + error, + exc_info=True, + ) + + async def _deliver_user_topup_notification( + self, payload: _UserNotificationPayload + ) -> None: + bot_instance = getattr(self, "bot", None) + if not bot_instance: + return + + try: + await bot_instance.send_message( + payload.telegram_id, + payload.text, + parse_mode=payload.parse_mode, + reply_markup=payload.reply_markup, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)", + payload.telegram_id, + f"{payload.amount_rubles:.2f}", + payload.asset, + ) + except Exception as error: + logger.error( + "Ошибка отправки уведомления о пополнении CryptoBot: %s", + error, + ) + + async def _deliver_saved_cart_reminder( + self, payload: _SavedCartNotificationPayload + ) -> None: + bot_instance = getattr(self, "bot", None) + if not bot_instance: + return + + try: + await bot_instance.send_message( + chat_id=payload.telegram_id, + text=payload.text, + reply_markup=payload.reply_markup, + ) + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + payload.user_id, + ) + except Exception as error: + logger.error( + "Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s", + payload.user_id, + error, + exc_info=True, + ) + async def get_cryptobot_payment_status( self, db: AsyncSession, diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index a0f814af..0b90e859 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -17,6 +17,7 @@ if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) import app.services.payment_service as payment_service_module # noqa: E402 +import app.services.payment.cryptobot as cryptobot_module # noqa: E402 from app.services.payment_service import PaymentService # noqa: E402 from app.database.models import PaymentMethod # noqa: E402 from app.config import settings # noqa: E402 @@ -283,13 +284,21 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module) transactions: list[Dict[str, Any]] = [] + created_transaction: SimpleNamespace | None = None async def fake_create_transaction(db, **kwargs): + nonlocal created_transaction transactions.append(kwargs) - return SimpleNamespace(id=888, **kwargs) + created_transaction = SimpleNamespace(id=888, **kwargs) + return created_transaction fake_transaction_module = ModuleType("app.database.crud.transaction") fake_transaction_module.create_transaction = fake_create_transaction + + async def fake_get_transaction_by_id(db, transaction_id): + return created_transaction + + fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module) monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) @@ -310,6 +319,10 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto) + fake_user_module = ModuleType("app.database.crud.user") + fake_user_module.get_user_by_id = fake_get_user_crypto + monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module) + referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock()) monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto) @@ -323,6 +336,18 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch admin_calls.append((args, kwargs)) monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot))) + + class DummyAsyncSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def rollback(self): # pragma: no cover - defensive stub + return None + + monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession()) monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0)) monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) service.build_topup_success_keyboard = AsyncMock(return_value=None)