From 1a955d920a8c0567d690159bb5f1a62800c2f9a9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 2 Nov 2025 06:13:16 +0300 Subject: [PATCH] Handle repeated return-to-cart callbacks without redundant edits --- app/handlers/subscription/purchase.py | 66 +++++++++-- app/keyboards/inline.py | 9 +- app/services/payment/common.py | 25 ++++- app/services/payment/cryptobot.py | 2 +- app/services/payment/mulenpay.py | 2 +- app/services/payment/pal24.py | 2 +- app/services/payment/stars.py | 2 +- app/services/payment/wata.py | 2 +- app/services/payment/yookassa.py | 2 +- .../services/test_payment_service_webhooks.py | 2 +- tests/test_subscription_cart_integration.py | 103 ++++++++++++++++-- 11 files changed, 184 insertions(+), 33 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 06df0798..8350d095 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -58,6 +58,39 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.services.subscription_service import SubscriptionService + + +def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]: + if markup is None: + return None + + model_dump = getattr(markup, "model_dump", None) + if callable(model_dump): + try: + return model_dump(exclude_none=True) + except TypeError: + return model_dump() + + to_python = getattr(markup, "to_python", None) + if callable(to_python): + return to_python() + + return markup + + +def _message_needs_update( + message: types.Message, + new_text: str, + new_markup: Optional[InlineKeyboardMarkup], +) -> bool: + current_text = getattr(message, "text", None) + + if current_text != new_text: + return True + + current_markup = getattr(message, "reply_markup", None) + + return _serialize_markup(current_markup) != _serialize_markup(new_markup) from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.services.promo_offer_service import promo_offer_service from app.states import SubscriptionStates @@ -800,16 +833,24 @@ async def return_to_saved_cart( if db_user.balance_kopeks < total_price: missing_amount = total_price - db_user.balance_kopeks - await callback.message.edit_text( + insufficient_keyboard = get_insufficient_balance_keyboard_with_cart( + db_user.language, + missing_amount, + ) + insufficient_text = ( f"❌ Все еще недостаточно средств\n\n" f"Требуется: {texts.format_price(total_price)}\n" f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" - f"Не хватает: {texts.format_price(missing_amount)}", - reply_markup=get_insufficient_balance_keyboard_with_cart( - db_user.language, - missing_amount, - ) + f"Не хватает: {texts.format_price(missing_amount)}" ) + + if _message_needs_update(callback.message, insufficient_text, insufficient_keyboard): + await callback.message.edit_text( + insufficient_text, + reply_markup=insufficient_keyboard, + ) + else: + await callback.answer("ℹ️ Пополните баланс, чтобы завершить оформление.") return countries = await _get_available_countries(db_user.promo_group_id) @@ -856,11 +897,14 @@ async def return_to_saved_cart( await state.set_data(prepared_cart_data) await state.set_state(SubscriptionStates.confirming_purchase) - await callback.message.edit_text( - summary_text, - reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language), - parse_mode="HTML" - ) + confirm_keyboard = get_subscription_confirm_keyboard_with_cart(db_user.language) + + if _message_needs_update(callback.message, summary_text, confirm_keyboard): + await callback.message.edit_text( + summary_text, + reply_markup=confirm_keyboard, + parse_mode="HTML" + ) await callback.answer("✅ Корзина восстановлена!") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 4ce7a25d..5a41a172 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -356,10 +356,13 @@ def get_main_menu_keyboard( paired_buttons.append(simple_purchase_button) if show_resume_checkout or has_saved_cart: + resume_callback = ( + "return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout" + ) paired_buttons.append( InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data=resume_callback, ) ) @@ -671,7 +674,7 @@ def get_insufficient_balance_keyboard( return_row = [ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ] insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) @@ -803,7 +806,7 @@ def get_payment_methods_keyboard_with_cart( keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад" InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" + callback_data="return_to_saved_cart" ) ]) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index d9b949da..3fe7f99c 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -23,6 +23,7 @@ from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) +from app.services.user_cart_service import user_cart_service from app.utils.miniapp_buttons import build_miniapp_or_callback_button logger = logging.getLogger(__name__) @@ -56,14 +57,32 @@ class PaymentCommonMixin: # Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему. if user: - draft_exists = await has_subscription_checkout_draft(user.id) - if should_offer_checkout_resume(user, draft_exists): + try: + has_saved_cart = await user_cart_service.has_user_cart(user.id) + except Exception as cart_error: + logger.warning( + "Не удалось проверить наличие сохраненной корзины у пользователя %s: %s", + user.id, + cart_error, + ) + has_saved_cart = False + + if has_saved_cart: keyboard_rows.append([ build_miniapp_or_callback_button( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ]) + else: + draft_exists = await has_subscription_checkout_draft(user.id) + if should_offer_checkout_resume(user, draft_exists): + keyboard_rows.append([ + build_miniapp_or_callback_button( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) # Стандартные кнопки быстрого доступа к балансу и главному меню. keyboard_rows.append([ diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index bc97ff1c..c3a3a527 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -332,7 +332,7 @@ class CryptoBotPaymentMixin: keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" + callback_data="return_to_saved_cart" )], [types.InlineKeyboardButton( text="💰 Мой баланс", diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 951b7ee7..d7514a8b 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -366,7 +366,7 @@ class MulenPayPaymentMixin: keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" + callback_data="return_to_saved_cart" )], [types.InlineKeyboardButton( text="💰 Мой баланс", diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index e876d6da..927bc443 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -473,7 +473,7 @@ class Pal24PaymentMixin: "BALANCE_TOPUP_CART_BUTTON", "🛒 Продолжить оформление", ), - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ], [ diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 6d728a47..da2f68ed 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -584,7 +584,7 @@ class TelegramStarsMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ], [ diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index b97ea43e..c0b7e339 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -561,7 +561,7 @@ class WataPaymentMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ], [ diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 778d35c2..974e523e 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -534,7 +534,7 @@ class YooKassaPaymentMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", + callback_data="return_to_saved_cart", ) ], [ diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index e71602a8..0381f2cf 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -882,7 +882,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - saved_cart_message = bot.sent_messages[-1] reply_markup = saved_cart_message["kwargs"].get("reply_markup") assert reply_markup is not None - assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout" + assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "return_to_saved_cart" assert admin_calls diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py index 4f633e4b..ce0064d1 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -1,7 +1,13 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, User as TgUser, Message +from aiogram.types import ( + CallbackQuery, + User as TgUser, + Message, + InlineKeyboardButton, + InlineKeyboardMarkup, +) from sqlalchemy.ext.asyncio import AsyncSession from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart from app.handlers.subscription.autopay import handle_subscription_cancel @@ -58,12 +64,14 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, # Подготовим моки mock_cart_service.save_user_cart = AsyncMock(return_value=True) - mock_keyboard = AsyncMock() + mock_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" mock_get_texts.return_value = mock_texts missing_amount = 40000 # 50000 - 10000 = 40000 @@ -119,12 +127,14 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] mock_format_period.return_value = "30 дней" - mock_keyboard = AsyncMock() + mock_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" mock_get_texts.return_value = mock_texts # Увеличиваем баланс пользователя, чтобы его хватило @@ -143,6 +153,77 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc mock_callback_query.answer.assert_called_once() +@pytest.mark.asyncio +async def test_return_to_saved_cart_skips_edit_when_message_matches( + mock_callback_query, + mock_state, + mock_user, + mock_db, +): + cart_data = { + 'period_days': 60, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 40, + 'total_price': 44000, + 'saved_cart': True, + 'user_id': mock_user.id, + } + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]] + ) + existing_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]] + ) + + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ + patch('app.localization.texts.get_texts') as mock_get_texts, \ + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ + patch('app.handlers.subscription.purchase.settings') as mock_settings: + + mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_cart_service.save_user_cart = AsyncMock() + mock_get_countries.return_value = [ + {'uuid': 'ru', 'name': 'Russia'}, + {'uuid': 'us', 'name': 'USA'}, + ] + mock_format_period.return_value = "60 дней" + mock_keyboard_func.return_value = confirm_keyboard + + mock_texts = AsyncMock() + mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" + mock_get_texts.return_value = mock_texts + + mock_settings.is_devices_selection_enabled.return_value = True + mock_settings.is_traffic_fixed.return_value = False + + mock_user.balance_kopeks = 50000 + + summary_text = ( + "🛒 Восстановленная корзина\n\n" + "📅 Период: 60 дней\n" + "📊 Трафик: 40 ГБ\n" + "🌍 Страны: Russia, USA\n" + "📱 Устройства: 3\n\n" + "💎 Общая стоимость: 440 ₽\n\n" + "Подтверждаете покупку?" + ) + + mock_callback_query.message.text = summary_text + mock_callback_query.message.reply_markup = existing_keyboard + + await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) + + mock_callback_query.message.edit_text.assert_not_called() + mock_callback_query.answer.assert_called_once_with("✅ Корзина восстановлена!") + mock_state.set_data.assert_called_once_with(cart_data) + mock_state.set_state.assert_called_once() + mock_cart_service.save_user_cart.assert_not_called() + + @pytest.mark.asyncio async def test_return_to_saved_cart_normalizes_devices_when_disabled( mock_callback_query, @@ -182,11 +263,13 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled( mock_cart_service.save_user_cart = AsyncMock() mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] mock_format_period.return_value = "30 дней" - mock_keyboard = AsyncMock() + mock_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + ) mock_keyboard_func.return_value = mock_keyboard mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" mock_texts.t = lambda key, default=None: default or "" mock_get_texts.return_value = mock_texts @@ -237,12 +320,14 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) - mock_keyboard = AsyncMock() + mock_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="Пополнить", callback_data="topup")]] + ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" mock_texts.t = lambda key, default: default mock_get_texts.return_value = mock_texts