diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py
index 18663d40..de032223 100644
--- a/app/handlers/balance/main.py
+++ b/app/handlers/balance/main.py
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_transactions
from app.database.models import TransactionType, User
+from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
from app.keyboards.inline import (
get_back_keyboard,
get_balance_keyboard,
@@ -879,3 +880,7 @@ 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|'))
+
+ 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 9b8e8622..1196e93e 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -1504,8 +1504,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)
@@ -1941,6 +1950,68 @@ 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 9baa5091..2bf26ffd 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -1142,11 +1142,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,