diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 2eef6061..af625732 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -247,15 +247,7 @@ class Pal24PaymentMixin: return True if status in {"PAID", "SUCCESS", "OVERPAID"}: - user = await payment_module.get_user_by_id(db, payment.user_id) - if not user: - logger.error( - "Пользователь %s не найден для Pal24 платежа", - payment.user_id, - ) - return False - - await payment_module.update_pal24_payment_status( + payment = await payment_module.update_pal24_payment_status( db, payment, status=status, @@ -270,154 +262,22 @@ class Pal24PaymentMixin: or (payment.metadata_json or {}).get("selected_method") or getattr(payment, "payment_method", None) ), + balance_amount=postback.get("BalanceAmount") + or postback.get("balance_amount"), + balance_currency=postback.get("BalanceCurrency") + or postback.get("balance_currency"), + payer_account=postback.get("AccountNumber") + or postback.get("account") + or postback.get("Account"), ) - if payment.transaction_id: - logger.info( - "Для Pal24 платежа %s уже создана транзакция", - payment.bill_id, - ) - return True - - transaction = await payment_module.create_transaction( + return await self._finalize_pal24_payment( db, - user_id=payment.user_id, - type=TransactionType.DEPOSIT, - amount_kopeks=payment.amount_kopeks, - description=f"Пополнение через Pal24 ({payment_id})", - payment_method=PaymentMethod.PAL24, - external_id=str(payment_id) if payment_id else payment.bill_id, - is_completed=True, + payment, + payment_id=payment_id, + trigger="postback", ) - await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id) - - old_balance = user.balance_kopeks - was_first_topup = not user.has_made_first_topup - - user.balance_kopeks += payment.amount_kopeks - user.updated_at = datetime.utcnow() - - promo_group = getattr(user, "promo_group", None) - subscription = getattr(user, "subscription", None) - referrer_info = format_referrer_info(user) - topup_status = ( - "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - ) - - await db.commit() - - try: - from app.services.referral_service import process_referral_topup - - await process_referral_topup( - db, user.id, payment.amount_kopeks, getattr(self, "bot", None) - ) - except Exception as error: - logger.error( - "Ошибка обработки реферального пополнения Pal24: %s", - error, - ) - - if was_first_topup and not user.has_made_first_topup: - user.has_made_first_topup = True - await db.commit() - - await db.refresh(user) - - if getattr(self, "bot", None): - try: - from app.services.admin_notification_service import ( - AdminNotificationService, - ) - - 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( - "Ошибка отправки админ уведомления Pal24: %s", error - ) - - 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(payment.amount_kopeks)}\n" - "🦊 Способ: PayPalych\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - except Exception as error: - logger.error( - "Ошибка отправки уведомления пользователю Pal24: %s", - error, - ) - - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки - try: - from app.services.user_cart_service import user_cart_service - from aiogram import types - has_saved_cart = await user_cart_service.has_user_cart(user.id) - if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts - - texts = get_texts(user.language) - cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами." - ) - - # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - 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{cart_message}", - reply_markup=keyboard - ) - logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}") - except Exception as e: - logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) - - logger.info( - "✅ Обработан Pal24 платеж %s для пользователя %s", - payment.bill_id, - payment.user_id, - ) - - return True - await payment_module.update_pal24_payment_status( db, payment, @@ -431,6 +291,13 @@ class Pal24PaymentMixin: or postback.get("PaymentMethod") or getattr(payment, "payment_method", None) ), + balance_amount=postback.get("BalanceAmount") + or postback.get("balance_amount"), + balance_currency=postback.get("BalanceCurrency") + or postback.get("balance_currency"), + payer_account=postback.get("AccountNumber") + or postback.get("account") + or postback.get("Account"), ) logger.info( "Обновили Pal24 платеж %s до статуса %s", @@ -443,6 +310,189 @@ class Pal24PaymentMixin: logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True) return False + async def _finalize_pal24_payment( + self, + db: AsyncSession, + payment: Any, + *, + payment_id: Optional[str], + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" + + payment_module = import_module("app.services.payment_service") + + if payment.transaction_id: + logger.info( + "Pal24 платеж %s уже привязан к транзакции (trigger=%s)", + payment.bill_id, + trigger, + ) + return True + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error( + "Пользователь %s не найден для Pal24 платежа %s (trigger=%s)", + payment.user_id, + payment.bill_id, + trigger, + ) + return False + + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})", + payment_method=PaymentMethod.PAL24, + external_id=str(payment_id) if payment_id else payment.bill_id, + is_completed=True, + ) + + await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.utcnow() + + promo_group = getattr(user, "promo_group", None) + subscription = getattr(user, "subscription", None) + referrer_info = format_referrer_info(user) + topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + + await db.commit() + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, user.id, payment.amount_kopeks, getattr(self, "bot", None) + ) + except Exception as error: + logger.error( + "Ошибка обработки реферального пополнения Pal24: %s", + error, + ) + + if was_first_topup and not user.has_made_first_topup: + user.has_made_first_topup = True + await db.commit() + + await db.refresh(user) + await db.refresh(payment) + + if getattr(self, "bot", None): + try: + from app.services.admin_notification_service import ( + AdminNotificationService, + ) + + 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( + "Ошибка отправки админ уведомления Pal24: %s", + error, + ) + + 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(payment.amount_kopeks)}\n" + "🦊 Способ: PayPalych\n" + f"🆔 Транзакция: {transaction.id}\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as error: + logger.error( + "Ошибка отправки уведомления пользователю Pal24: %s", + error, + ) + + try: + from app.services.user_cart_service import user_cart_service + from aiogram import types + + has_saved_cart = await user_cart_service.has_user_cart(user.id) + if has_saved_cart and getattr(self, "bot", None): + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "У вас есть незавершенное оформление подписки. Вернуться?", + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "BALANCE_TOPUP_CART_BUTTON", + "🛒 Продолжить оформление", + ), + callback_data="resume_cart", + ) + ], + [ + types.InlineKeyboardButton( + text="🏠 Главное меню", + 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{cart_message}" + ), + reply_markup=keyboard, + ) + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + user.id, + ) + except Exception as error: + logger.error( + "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + user.id, + error, + exc_info=True, + ) + + logger.info( + "✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)", + payment.bill_id, + payment.user_id, + trigger, + ) + + return True + + async def get_pal24_payment_status( self, db: AsyncSession, @@ -456,49 +506,72 @@ class Pal24PaymentMixin: if not payment: return None - remote_status = None - remote_data = None + remote_status: Optional[str] = None + remote_data: Optional[Dict[str, Any]] = None service = getattr(self, "pal24_service", None) if service and payment.bill_id: try: response = await service.get_bill_status(payment.bill_id) remote_data = response - remote_status = response.get("status") or response.get( - "bill", {} - ).get("status") + remote_status = response.get("status") or response.get("bill", {}).get("status") + + payment_info = self._extract_remote_payment_info(response) if remote_status: normalized_remote = str(remote_status).upper() - if normalized_remote != payment.status: - update_kwargs: Dict[str, Any] = { - "status": normalized_remote, - "payment_status": remote_status, - } - if normalized_remote in getattr( - service, "BILL_SUCCESS_STATES", {"SUCCESS"} - ): - update_kwargs["is_paid"] = True - if not payment.paid_at: - update_kwargs["paid_at"] = datetime.utcnow() - elif normalized_remote in getattr( - service, "BILL_FAILED_STATES", {"FAIL"} - ): - update_kwargs["is_paid"] = False + update_kwargs: Dict[str, Any] = { + "status": normalized_remote, + "payment_status": payment_info.get("status") or remote_status, + } - await payment_module.update_pal24_payment_status( - db, - payment, - **update_kwargs, - ) - payment = await payment_module.get_pal24_payment_by_id( - db, local_payment_id + if payment_info.get("id"): + update_kwargs["payment_id"] = payment_info["id"] + if payment_info.get("method"): + update_kwargs["payment_method"] = payment_info["method"] + if payment_info.get("balance_amount"): + update_kwargs["balance_amount"] = payment_info["balance_amount"] + if payment_info.get("balance_currency"): + update_kwargs["balance_currency"] = payment_info["balance_currency"] + if payment_info.get("account"): + update_kwargs["payer_account"] = payment_info["account"] + + if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}): + update_kwargs["is_paid"] = True + if not payment.paid_at: + update_kwargs["paid_at"] = datetime.utcnow() + elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}): + update_kwargs["is_paid"] = False + elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}): + update_kwargs.setdefault("is_paid", False) + + payment = await payment_module.update_pal24_payment_status( + db, + payment, + **update_kwargs, ) except Pal24APIError as error: logger.error( "Ошибка Pal24 API при получении статуса: %s", error ) + if payment.is_paid and not payment.transaction_id: + try: + finalized = await self._finalize_pal24_payment( + db, + payment, + payment_id=getattr(payment, "payment_id", None), + trigger="status_check", + ) + if finalized: + payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) + except Exception as error: + logger.error( + "Ошибка автоматического начисления по Pal24 статусу: %s", + error, + exc_info=True, + ) + return { "payment": payment, "status": payment.status, @@ -511,6 +584,65 @@ class Pal24PaymentMixin: logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True) return None + + @staticmethod + def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]: + """Извлекает данные о платеже из ответа Pal24.""" + + def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]: + if isinstance(value, dict): + return value + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + return item + return None + + def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]: + def _stringify(value: Any) -> Optional[str]: + if value is None: + return None + return str(value) + + return { + "id": _stringify(candidate.get("id") or candidate.get("payment_id")), + "status": _stringify(candidate.get("status")), + "method": _stringify(candidate.get("method") or candidate.get("payment_method")), + "balance_amount": _stringify( + candidate.get("balance_amount") + or candidate.get("amount") + or candidate.get("BalanceAmount") + ), + "balance_currency": _stringify( + candidate.get("balance_currency") or candidate.get("BalanceCurrency") + ), + "account": _stringify( + candidate.get("account") + or candidate.get("payer_account") + or candidate.get("AccountNumber") + ), + } + + if not isinstance(remote_data, dict): + return {} + + search_spaces = [remote_data] + bill_section = remote_data.get("bill") or remote_data.get("Bill") + if isinstance(bill_section, dict): + search_spaces.append(bill_section) + + for space in search_spaces: + for key in ("payment", "Payment", "payment_info", "PaymentInfo"): + candidate = _pick_candidate(space.get(key)) + if candidate: + return _normalize(candidate) + for key in ("payments", "Payments"): + candidate = _pick_candidate(space.get(key)) + if candidate: + return _normalize(candidate) + + return {} + @staticmethod def _normalize_payment_method(payment_method: Optional[str]) -> str: mapping = { diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 47e9bf1c..36e1b746 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -372,6 +372,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - transaction_id=None, is_paid=False, status="NEW", + metadata_json={}, + payment_method=None, + paid_at=None, ) async def fake_get_by_order(db, order_id): @@ -439,7 +442,30 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - async def send_balance_topup_notification(self, *args, **kwargs): admin_calls.append((args, kwargs)) - monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot))) + monkeypatch.setitem( + sys.modules, + "app.services.admin_notification_service", + SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)), + ) + + user_cart_stub = SimpleNamespace( + user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False)) + ) + monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + + class DummyTypes: + class InlineKeyboardMarkup: + def __init__(self, inline_keyboard=None, **kwargs): + self.inline_keyboard = inline_keyboard or [] + self.kwargs = kwargs + + class InlineKeyboardButton: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes)) + service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { @@ -458,6 +484,141 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - assert admin_calls +@pytest.mark.anyio("asyncio") +async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + + class DummyPal24Service: + BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"} + BILL_FAILED_STATES = {"FAIL"} + BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"} + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + return { + "status": "SUCCESS", + "bill": { + "status": "SUCCESS", + "payments": [ + { + "id": "trs-auto-1", + "status": "SUCCESS", + "method": "SBP", + "balance_amount": "50.00", + "balance_currency": "RUB", + } + ], + }, + } + + service.pal24_service = DummyPal24Service() + + fake_session = FakeSession() + payment = SimpleNamespace( + id=77, + bill_id="BILL-AUTO", + order_id="order-auto", + amount_kopeks=5000, + user_id=91, + transaction_id=None, + is_paid=False, + status="NEW", + metadata_json={}, + payment_id=None, + payment_method=None, + paid_at=None, + ) + + async def fake_get_payment_by_id(db, local_id): + return payment + + async def fake_update_payment(db, payment_obj, **kwargs): + for key, value in kwargs.items(): + setattr(payment, key, value) + return payment + + async def fake_link_payment(db, payment_obj, transaction_id): + payment.transaction_id = transaction_id + return payment + + monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id) + monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment) + monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment) + + transactions: list[Dict[str, Any]] = [] + + async def fake_create_transaction(db, **kwargs): + transactions.append(kwargs) + payment.transaction_id = 999 + return SimpleNamespace(id=999, **kwargs) + + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + + user = SimpleNamespace( + id=91, + telegram_id=9100, + balance_kopeks=0, + has_made_first_topup=False, + promo_group=None, + subscription=None, + referred_by_id=None, + referrer=None, + language="ru", + ) + + async def fake_get_user(db, user_id): + return user + + monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + + referral_stub = SimpleNamespace(process_referral_topup=AsyncMock()) + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub) + + admin_notifications: list[Any] = [] + + class DummyAdminService: + def __init__(self, bot): + self.bot = bot + + async def send_balance_topup_notification(self, *args, **kwargs): + admin_notifications.append((args, kwargs)) + + monkeypatch.setitem( + sys.modules, + "app.services.admin_notification_service", + SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + ) + + user_cart_stub = SimpleNamespace( + user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False)) + ) + monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + + class DummyTypes: + class InlineKeyboardMarkup: + def __init__(self, inline_keyboard=None, **kwargs): + self.inline_keyboard = inline_keyboard or [] + self.kwargs = kwargs + + class InlineKeyboardButton: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes)) + + service.build_topup_success_keyboard = AsyncMock(return_value=None) + + result = await service.get_pal24_payment_status(fake_session, payment.id) + + assert result is not None + assert payment.transaction_id == 999 + assert user.balance_kopeks == 5000 + assert bot.sent_messages + assert admin_notifications + assert transactions and transactions[0]["user_id"] == 91 + @pytest.mark.anyio("asyncio") async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot()