Merge pull request #2721 from FireWookie/feature/recurrent_method_inline

Отображение привязанных карт в разделе в боте
This commit is contained in:
Egor
2026-03-12 20:28:31 +03:00
committed by GitHub
10 changed files with 251 additions and 2 deletions
+5
View File
@@ -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_'))
+6
View File
@@ -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',
+90
View File
@@ -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',
'💳 <b>Привязанные карты</b>\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',
'💳 <b>Привязанные карты</b>\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',
'Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\n'
'После отвязки автоплатеж не сможет использовать эту карту.',
).format(card=card_label)
if len(cards) == 1:
text += texts.t(
'SAVED_CARDS_LAST_CARD_WARNING',
'\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. '
'После отвязки автоплатеж не сможет списывать средства.',
)
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
):
+72 -1
View File
@@ -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 = []
+14
View File
@@ -899,6 +899,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\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": "💳 <b>Saved cards</b>\n\nSelect a card to unlink:",
"SAVED_CARDS_EMPTY": "💳 <b>Saved cards</b>\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 <b>{card}</b>?\n\nAfter unlinking, autopay won't be able to use this card.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Warning:</b> 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",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
"SAVED_CARDS_BUTTON": "💳 کارت‌های ذخیره شده",
"SAVED_CARDS_TITLE": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت مورد نظر برای حذف را انتخاب کنید:",
"SAVED_CARDS_EMPTY": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت ذخیره شده‌ای وجود ندارد.\nکارت به صورت خودکار در شارژ بعدی موجودی ذخیره می‌شود.",
"SAVED_CARDS_CONFIRM_UNLINK": "آیا مطمئن هستید که می‌خواهید کارت <b>{card}</b> را حذف کنید؟\n\nپس از حذف، پرداخت خودکار نمی‌تواند از این کارت استفاده کند.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>توجه:</b> این آخرین کارت ذخیره شده شماست. پس از حذف، پرداخت خودکار امکان برداشت وجه نخواهد داشت.",
"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": "⬅️ منوی اصلی",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
"SAVED_CARDS_BUTTON": "💳 Привязанные карты",
"SAVED_CARDS_TITLE": "💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:",
"SAVED_CARDS_EMPTY": "💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\nКарта привяжется автоматически при следующем пополнении баланса.",
"SAVED_CARDS_CONFIRM_UNLINK": "Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\nПосле отвязки автоплатеж не сможет использовать эту карту.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. После отвязки автоплатеж не сможет списывать средства.",
"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": "⬅️ В главное меню",
+14
View File
@@ -841,6 +841,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
"SAVED_CARDS_BUTTON": "💳 Прив'язані картки",
"SAVED_CARDS_TITLE": "💳 <b>Прив'язані картки</b>\n\nОберіть картку для відв'язки:",
"SAVED_CARDS_EMPTY": "💳 <b>Прив'язані картки</b>\n\nНемає прив'язаних карток.\nКартка прив'яжеться автоматично при наступному поповненні балансу.",
"SAVED_CARDS_CONFIRM_UNLINK": "Ви впевнені, що хочете відв'язати картку <b>{card}</b>?\n\nПісля відв'язки автоплатіж не зможе використовувати цю картку.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Увага:</b> це ваша остання прив'язана картка. Після відв'язки автоплатіж не зможе списувати кошти.",
"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": "🏠 В головне меню",
+14
View File
@@ -839,6 +839,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
"SAVED_CARDS_BUTTON": "💳 已绑定的卡",
"SAVED_CARDS_TITLE": "💳 <b>已绑定的卡</b>\n\n选择要解绑的卡:",
"SAVED_CARDS_EMPTY": "💳 <b>已绑定的卡</b>\n\n没有已绑定的卡。\n下次充值余额时将自动绑定。",
"SAVED_CARDS_CONFIRM_UNLINK": "确定要解绑 <b>{card}</b> 吗?\n\n解绑后自动扣款将无法使用此卡。",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>注意:</b>这是您最后一张绑定的卡。解绑后自动扣款将无法进行。",
"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": "🏠返回主菜单",
+8 -1
View File
@@ -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,