From 23ff40cd2c24c1410d3565a92c163eb612682ef5 Mon Sep 17 00:00:00 2001 From: firewookie Date: Thu, 12 Mar 2026 09:31:46 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=B0=D1=80=D1=82=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=20=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/balance/main.py | 6 ++ app/handlers/subscription/__init__.py | 6 ++ app/handlers/subscription/autopay.py | 90 +++++++++++++++++++++++++++ app/keyboards/inline.py | 83 +++++++++++++++++++++++- app/localization/locales/en.json | 14 +++++ app/localization/locales/fa.json | 14 +++++ app/localization/locales/ru.json | 14 +++++ app/localization/locales/ua.json | 14 +++++ app/localization/locales/zh.json | 14 +++++ app/services/payment/yookassa.py | 9 ++- 10 files changed, 262 insertions(+), 2 deletions(-) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 18663d40..c3b836c5 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -879,3 +879,9 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_')) dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|')) + + from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card + + dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list') + dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_')) + dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_')) diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py index 633ef812..a9e5cd2b 100644 --- a/app/handlers/subscription/__init__.py +++ b/app/handlers/subscription/__init__.py @@ -2,8 +2,11 @@ from .autopay import ( handle_autopay_menu, + handle_confirm_unlink, + handle_saved_cards_list, handle_subscription_cancel, handle_subscription_config_back, + handle_unlink_card, set_autopay_days, show_autopay_days, toggle_autopay, @@ -157,6 +160,7 @@ __all__ = [ 'handle_app_selection', 'handle_autopay_menu', 'handle_change_devices', + 'handle_confirm_unlink', 'handle_connect_subscription', 'handle_device_guide', 'handle_device_management', @@ -172,12 +176,14 @@ __all__ = [ 'handle_promo_offer_close', 'handle_reset_devices', 'handle_reset_traffic', + 'handle_saved_cards_list', 'handle_single_device_reset', 'handle_specific_app_guide', 'handle_subscription_cancel', 'handle_subscription_config_back', 'handle_subscription_settings', 'handle_switch_traffic', + 'handle_unlink_card', 'invalidate_app_config_cache', 'load_app_config_async', 'normalize_app', diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index a99439b1..96992566 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -3,13 +3,20 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.saved_payment_method import ( + deactivate_payment_method, + get_active_payment_methods_by_user, +) from app.database.crud.subscription import update_subscription_autopay from app.database.models import User from app.keyboards.inline import ( + _get_payment_method_display_name, get_autopay_days_keyboard, get_autopay_keyboard, + get_confirm_unlink_keyboard, get_countries_keyboard, get_devices_keyboard, + get_saved_cards_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, ) @@ -134,6 +141,89 @@ async def set_autopay_days(callback: types.CallbackQuery, db_user: User, db: Asy await handle_autopay_menu(callback, db_user, db) +async def handle_saved_cards_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + texts = get_texts(db_user.language) + cards = await get_active_payment_methods_by_user(db, db_user.id) + + if not cards: + await callback.message.edit_text( + texts.t( + 'SAVED_CARDS_EMPTY', + '💳 Привязанные карты\n\nНет привязанных карт.\n' + 'Карта привяжется автоматически при следующем пополнении баланса.', + ), + reply_markup=get_saved_cards_keyboard([], db_user.language), + parse_mode='HTML', + ) + else: + await callback.message.edit_text( + texts.t( + 'SAVED_CARDS_TITLE', + '💳 Привязанные карты\n\nВыберите карту для отвязки:', + ), + reply_markup=get_saved_cards_keyboard(cards, db_user.language), + parse_mode='HTML', + ) + await callback.answer() + + +async def handle_unlink_card(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + texts = get_texts(db_user.language) + card_id = int(callback.data.split('_')[-1]) + + cards = await get_active_payment_methods_by_user(db, db_user.id) + card = next((c for c in cards if c.id == card_id), None) + + if not card: + await callback.answer( + texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'), + show_alert=True, + ) + return + + card_label = _get_payment_method_display_name(card, db_user.language) + text = texts.t( + 'SAVED_CARDS_CONFIRM_UNLINK', + 'Вы уверены, что хотите отвязать карту {card}?\n\n' + 'После отвязки автоплатеж не сможет использовать эту карту.', + ).format(card=card_label) + + if len(cards) == 1: + text += texts.t( + 'SAVED_CARDS_LAST_CARD_WARNING', + '\n\n⚠️ Внимание: это ваша последняя привязанная карта. ' + 'После отвязки автоплатеж не сможет списывать средства.', + ) + + await callback.message.edit_text( + text, + reply_markup=get_confirm_unlink_keyboard(card_id, db_user.language), + parse_mode='HTML', + ) + await callback.answer() + + +async def handle_confirm_unlink(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + texts = get_texts(db_user.language) + card_id = int(callback.data.split('_')[-1]) + + success = await deactivate_payment_method(db, card_id, db_user.id) + + if success: + await callback.answer( + texts.t('SAVED_CARDS_UNLINKED', '✅ Карта отвязана'), + ) + else: + await callback.answer( + texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'), + show_alert=True, + ) + return + + # Return to the updated cards list + await handle_saved_cards_list(callback, db_user, db) + + async def handle_subscription_config_back( callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession ): diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7083e1e9..5249bce0 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1493,6 +1493,8 @@ def get_subscription_confirm_keyboard(language: str = DEFAULT_LANGUAGE) -> Inlin def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + from app.config import settings + texts = get_texts(language) keyboard = [ @@ -1500,8 +1502,17 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data='balance_history'), InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data='balance_topup'), ], - [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], ] + if settings.YOOKASSA_RECURRENT_ENABLED: + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('SAVED_CARDS_BUTTON', '💳 Привязанные карты'), + callback_data='saved_cards_list', + ) + ] + ) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1937,6 +1948,76 @@ def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark ) +_PAYMENT_METHOD_LOCALE_KEYS: dict[str, tuple[str, str]] = { + 'bank_card': ('PAYMENT_METHOD_BANK_CARD', '💳 Банковская карта'), + 'yoo_money': ('PAYMENT_METHOD_YOO_MONEY', '🟣 ЮMoney'), + 'sberbank': ('PAYMENT_METHOD_SBERBANK', '🟢 СберPay'), + 'tinkoff_bank': ('PAYMENT_METHOD_TINKOFF_BANK', '🟡 Т-Банк'), + 'sbp': ('PAYMENT_METHOD_SBP', '🏦 СБП'), + 'mir_pay': ('PAYMENT_METHOD_MIR_PAY', '🟦 Mir Pay'), +} + + +def _get_payment_method_display_name(card, language: str = DEFAULT_LANGUAGE) -> str: + """Локализованное название метода оплаты + реквизиты.""" + texts = get_texts(language) + + # Для банковских карт title уже содержит тип + маску (например "Visa *4444") + if card.method_type == 'bank_card' or (not card.method_type and card.card_last4): + if card.title: + return card.title + if card.card_last4: + return f'{card.card_type or "Card"} *{card.card_last4}' + + # Для остальных методов: локализованное название + реквизиты из title + locale_entry = _PAYMENT_METHOD_LOCALE_KEYS.get(card.method_type) + if locale_entry: + key, default = locale_entry + method_name = texts.t(key, default) + else: + method_name = card.method_type or 'Card' + + if card.title: + return f'{method_name} {card.title}' + return method_name + + +def get_saved_cards_keyboard( + cards: list, language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + for card in cards: + card_label = f'🗑 {_get_payment_method_display_name(card, language)}' + keyboard.append( + [InlineKeyboardButton(text=card_label, callback_data=f'unlink_card_{card.id}')] + ) + keyboard.append( + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_confirm_unlink_keyboard( + card_id: int, language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('SAVED_CARDS_CONFIRM_YES', '✅ Да, отвязать'), + callback_data=f'confirm_unlink_{card_id}', + ), + InlineKeyboardButton( + text=texts.t('CANCEL', '❌ Отмена'), + callback_data='saved_cards_list', + ), + ] + ] + ) + + def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 7b18f305..5ad8b089 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -899,6 +899,20 @@ "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Auto-payment completed\n\nBalance topped up by {amount} for subscription renewal.", "RECURRENT_TOPUP_FAILED": "❌ Auto-payment failed\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.", + "SAVED_CARDS_BUTTON": "💳 Saved cards", + "SAVED_CARDS_TITLE": "💳 Saved cards\n\nSelect a card to unlink:", + "SAVED_CARDS_EMPTY": "💳 Saved cards\n\nNo saved cards.\nA card will be saved automatically on your next balance top-up.", + "SAVED_CARDS_CONFIRM_UNLINK": "Are you sure you want to unlink {card}?\n\nAfter unlinking, autopay won't be able to use this card.", + "SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ Warning: this is your last saved card. After unlinking, autopay won't be able to charge payments.", + "SAVED_CARDS_UNLINKED": "✅ Card unlinked", + "SAVED_CARDS_UNLINK_ERROR": "❌ Failed to unlink card", + "SAVED_CARDS_CONFIRM_YES": "✅ Yes, unlink", + "PAYMENT_METHOD_BANK_CARD": "💳 Bank card", + "PAYMENT_METHOD_YOO_MONEY": "🟣 YooMoney", + "PAYMENT_METHOD_SBERBANK": "🟢 SberPay", + "PAYMENT_METHOD_TINKOFF_BANK": "🟡 T-Bank", + "PAYMENT_METHOD_SBP": "🏦 SBP", + "PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay", "BACK": "⬅️ Back", "BACK_BUTTON": "◀️ Back", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index 98ecb71e..c0dd6d7d 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -919,6 +919,20 @@ "AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ پرداخت خودکار انجام شد\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.", "RECURRENT_TOPUP_FAILED": "❌ پرداخت خودکار ناموفق بود\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.", + "SAVED_CARDS_BUTTON": "💳 کارت‌های ذخیره شده", + "SAVED_CARDS_TITLE": "💳 کارت‌های ذخیره شده\n\nکارت مورد نظر برای حذف را انتخاب کنید:", + "SAVED_CARDS_EMPTY": "💳 کارت‌های ذخیره شده\n\nکارت ذخیره شده‌ای وجود ندارد.\nکارت به صورت خودکار در شارژ بعدی موجودی ذخیره می‌شود.", + "SAVED_CARDS_CONFIRM_UNLINK": "آیا مطمئن هستید که می‌خواهید کارت {card} را حذف کنید؟\n\nپس از حذف، پرداخت خودکار نمی‌تواند از این کارت استفاده کند.", + "SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ توجه: این آخرین کارت ذخیره شده شماست. پس از حذف، پرداخت خودکار امکان برداشت وجه نخواهد داشت.", + "SAVED_CARDS_UNLINKED": "✅ کارت حذف شد", + "SAVED_CARDS_UNLINK_ERROR": "❌ حذف کارت انجام نشد", + "SAVED_CARDS_CONFIRM_YES": "✅ بله، حذف کن", + "PAYMENT_METHOD_BANK_CARD": "💳 کارت بانکی", + "PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney", + "PAYMENT_METHOD_SBERBANK": "🟢 СберPay", + "PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк", + "PAYMENT_METHOD_SBP": "🏦 СБП", + "PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay", "BACK": "⬅️ قبلی", "BACK_BUTTON": "◀️ بازگشت", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 13ca4cda..addc5b6d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -919,6 +919,20 @@ "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Автоплатёж выполнен\n\nБаланс пополнен на {amount} для продления подписки.", "RECURRENT_TOPUP_FAILED": "❌ Автоплатёж не удался\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.", + "SAVED_CARDS_BUTTON": "💳 Привязанные карты", + "SAVED_CARDS_TITLE": "💳 Привязанные карты\n\nВыберите карту для отвязки:", + "SAVED_CARDS_EMPTY": "💳 Привязанные карты\n\nНет привязанных карт.\nКарта привяжется автоматически при следующем пополнении баланса.", + "SAVED_CARDS_CONFIRM_UNLINK": "Вы уверены, что хотите отвязать карту {card}?\n\nПосле отвязки автоплатеж не сможет использовать эту карту.", + "SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ Внимание: это ваша последняя привязанная карта. После отвязки автоплатеж не сможет списывать средства.", + "SAVED_CARDS_UNLINKED": "✅ Карта отвязана", + "SAVED_CARDS_UNLINK_ERROR": "❌ Не удалось отвязать карту", + "SAVED_CARDS_CONFIRM_YES": "✅ Да, отвязать", + "PAYMENT_METHOD_BANK_CARD": "💳 Банковская карта", + "PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney", + "PAYMENT_METHOD_SBERBANK": "🟢 СберPay", + "PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк", + "PAYMENT_METHOD_SBP": "🏦 СБП", + "PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay", "BACK": "⬅️ Назад", "BACK_BUTTON": "◀️ Назад", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 9de80740..2c03e93d 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -841,6 +841,20 @@ "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Автоплатіж виконано\n\nБаланс поповнено на {amount} для продовження підписки.", "RECURRENT_TOPUP_FAILED": "❌ Автоплатіж не вдався\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.", + "SAVED_CARDS_BUTTON": "💳 Прив'язані картки", + "SAVED_CARDS_TITLE": "💳 Прив'язані картки\n\nОберіть картку для відв'язки:", + "SAVED_CARDS_EMPTY": "💳 Прив'язані картки\n\nНемає прив'язаних карток.\nКартка прив'яжеться автоматично при наступному поповненні балансу.", + "SAVED_CARDS_CONFIRM_UNLINK": "Ви впевнені, що хочете відв'язати картку {card}?\n\nПісля відв'язки автоплатіж не зможе використовувати цю картку.", + "SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ Увага: це ваша остання прив'язана картка. Після відв'язки автоплатіж не зможе списувати кошти.", + "SAVED_CARDS_UNLINKED": "✅ Картку відв'язано", + "SAVED_CARDS_UNLINK_ERROR": "❌ Не вдалося відв'язати картку", + "SAVED_CARDS_CONFIRM_YES": "✅ Так, відв'язати", + "PAYMENT_METHOD_BANK_CARD": "💳 Банківська картка", + "PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney", + "PAYMENT_METHOD_SBERBANK": "🟢 СберPay", + "PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк", + "PAYMENT_METHOD_SBP": "🏦 СБП", + "PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay", "BACK": "⬅️ Назад", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню", "BACK_TO_MENU": "🏠 В головне меню", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index aba8f49a..4a8e0ce3 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -839,6 +839,20 @@ "AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}!", "RECURRENT_TOPUP_SUCCESS": "✅ 自动扣款成功\n\n余额已充值{amount},用于续订订阅。", "RECURRENT_TOPUP_FAILED": "❌ 自动扣款失败\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。", +"SAVED_CARDS_BUTTON": "💳 已绑定的卡", +"SAVED_CARDS_TITLE": "💳 已绑定的卡\n\n选择要解绑的卡:", +"SAVED_CARDS_EMPTY": "💳 已绑定的卡\n\n没有已绑定的卡。\n下次充值余额时将自动绑定。", +"SAVED_CARDS_CONFIRM_UNLINK": "确定要解绑 {card} 吗?\n\n解绑后自动扣款将无法使用此卡。", +"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ 注意:这是您最后一张绑定的卡。解绑后自动扣款将无法进行。", +"SAVED_CARDS_UNLINKED": "✅ 卡已解绑", +"SAVED_CARDS_UNLINK_ERROR": "❌ 解绑失败", +"SAVED_CARDS_CONFIRM_YES": "✅ 确认解绑", +"PAYMENT_METHOD_BANK_CARD": "💳 银行卡", +"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney", +"PAYMENT_METHOD_SBERBANK": "🟢 СберPay", +"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк", +"PAYMENT_METHOD_SBP": "🏦 СБП", +"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay", "BACK": "⬅️返回", "BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单", "BACK_TO_MENU": "🏠返回主菜单", diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 035c4b92..0865fe6f 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -1140,11 +1140,18 @@ class YooKassaPaymentMixin: expiry_year = str(raw_year) if raw_year is not None else None method_type = pm.get('type', 'bank_card') - # Формируем название + # Формируем title — только реквизиты без названия метода + # (локализованное название подставляется в UI через _get_payment_method_display_name) title = None if card_last4: type_label = card_type or 'Card' title = f'{type_label} *{card_last4}' + elif method_type != 'bank_card': + # Для не-карточных методов: yoo_money (account_number), sbp/sberbank (phone) и т.д. + account = pm.get('account_number') or pm.get('phone') + if account: + masked = account[-4:] if len(account) >= 4 else account + title = f'*{masked}' saved = await create_saved_payment_method( db=db,