From 5b98e1a40c5ade411e7c65f6c4fadb376393c3a5 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 18:07:18 +0300 Subject: [PATCH] Implement payment methods management and binding functionality - Added new handlers for managing payment methods, including viewing, binding, and deleting payment methods. - Introduced new inline keyboard options for payment method management in user interactions. - Enhanced the YooKassa service to support card binding with minimal payment requirements. - Updated localization files to include new messages related to payment methods and their management. --- bot/handlers/user/subscription.py | 128 ++++++++++++++++++++++++- bot/keyboards/inline/user_keyboards.py | 48 ++++++++++ bot/services/yookassa_service.py | 8 +- db/dal/user_billing_dal.py | 13 +++ locales/en.json | 14 +++ locales/ru.json | 14 +++ 6 files changed, 222 insertions(+), 3 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index a4f5703..fd27b2e 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -10,7 +10,9 @@ from config.settings import Settings from db.dal import payment_dal from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_payment_method_keyboard, - get_payment_url_keyboard, get_back_to_main_menu_markup) + get_payment_url_keyboard, get_back_to_main_menu_markup, + get_payment_methods_manage_keyboard, get_payment_method_delete_confirm_keyboard, + get_payment_method_details_keyboard, get_bind_url_keyboard) from bot.services.yookassa_service import YooKassaService from db.dal import user_billing_dal from bot.services.stars_service import StarsService @@ -18,6 +20,7 @@ from bot.services.crypto_pay_service import CryptoPayService from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService +from bot.services.yookassa_service import YooKassaService from bot.middlewares.i18n import JsonI18n from db.dal import subscription_dal from db.models import Subscription @@ -523,13 +526,15 @@ async def my_subscription_command_handler( else get_text("traffic_na") ) ) - # Build markup with auto-renew toggle if available + # Build markup with auto-renew toggle and payment methods if available base_markup = get_back_to_main_menu_markup(current_lang, i18n) kb = base_markup.inline_keyboard try: if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") kb = [[InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}")]] + kb + # Add payment methods manage entry point + kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb except Exception: pass markup = InlineKeyboardMarkup(inline_keyboard=kb) @@ -585,6 +590,125 @@ async def toggle_autorenew_handler(callback: types.CallbackQuery, settings: Sett await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) +@router.callback_query(F.data == "pm:manage") +async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + has_card = bool(billing and billing.yookassa_payment_method_id) + text = _("payment_methods_title") + if not has_card: + text += "\n\n" + _("payment_method_none") + await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:bind") +async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + # Create a minimal binding payment (1 RUB) with save_payment_method + metadata = { + "user_id": str(callback.from_user.id), + "bind_only": "1", + } + resp = await yookassa_service.create_payment( + amount=1.00, + currency="RUB", + description="Bind card", + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=True, + capture=False, + bind_only=True, + ) + if not resp or not resp.get("confirmation_url"): + await callback.answer(_("error_payment_gateway"), show_alert=True) + return + await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:delete_confirm") +async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:delete") +async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) + await session.commit() + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") + await callback.message.edit_text(msg, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=False)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:view") +async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + if not billing or not billing.yookassa_payment_method_id: + await callback.answer(_("payment_method_none"), show_alert=True) + return + added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—" + # Placeholder for last tx; real data requires querying payments + last_tx = "—" + title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4 or "????") + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:history") +async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + # Simple history from payments table filtered by user + from db.dal import payment_dal + payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10, offset=0) + user_payments = [p for p in payments if p.user_id == callback.from_user.id] + if not user_payments: + await callback.message.edit_text(_("payment_method_no_history"), reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)) + return + lines = [ + f"{p.created_at.strftime('%Y-%m-%d')} — {p.amount} {p.currency} — {p.provider} — {p.status}" + for p in user_payments + ] + text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) + await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)) + + @router.pre_checkout_query() async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery): await pre_checkout_query.answer(ok=True) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6a0898f..4aa5448 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -229,3 +229,51 @@ def get_connect_and_main_keyboard( ) return builder.as_markup() + + +def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + if has_card: + builder.row( + InlineKeyboardButton(text=_(key="payment_method_view_button"), callback_data="pm:view"), + InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data="pm:delete_confirm"), + ) + builder.row( + InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind") + ) + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main") + ) + return builder.as_markup() + + +def get_payment_method_delete_confirm_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="yes_button"), callback_data="pm:delete"), + InlineKeyboardButton(text=_(key="cancel_button"), callback_data="pm:manage"), + ) + return builder.as_markup() + + +def get_payment_method_details_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data="pm:history") + ) + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:manage") + ) + return builder.as_markup() + + +def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="payment_method_bind_button"), url=bind_url) + builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage") + builder.adjust(1) + return builder.as_markup() diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 1d98734..3fe4b20 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -6,6 +6,7 @@ from typing import Optional, Dict, Any, List from yookassa import Configuration, Payment as YooKassaPayment from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder from yookassa.domain.common.confirmation_type import ConfirmationType +from yookassa.domain.models.payment_data import PaymentMethodType from config.settings import Settings @@ -64,7 +65,8 @@ class YooKassaService: receipt_phone: Optional[str] = None, save_payment_method: bool = False, payment_method_id: Optional[str] = None, - capture: bool = True) -> Optional[Dict[str, Any]]: + capture: bool = True, + bind_only: bool = False) -> Optional[Dict[str, Any]]: if not self.configured: logging.error("YooKassa is not configured. Cannot create payment.") return None @@ -105,6 +107,10 @@ class YooKassaService: "value": str(round(amount, 2)), "currency": currency.upper() }) + # For binding cards only, do not capture and set minimal amount + if bind_only: + capture = False + amount = max(amount, 1.00) builder.set_capture(capture) builder.set_confirmation({ "type": ConfirmationType.REDIRECT, diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 597fd75..78d80a5 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -39,3 +39,16 @@ async def upsert_yk_payment_method( await session.flush() await session.refresh(record) return record + + +async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool: + existing = await get_user_billing(session, user_id) + if not existing: + return False + existing.yookassa_payment_method_id = None + existing.card_last4 = None + existing.card_network = None + existing.updated_at = func.now() + await session.flush() + await session.refresh(existing) + return True diff --git a/locales/en.json b/locales/en.json index 53a63f4..31619e9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -375,6 +375,20 @@ "autorenew_enable_button": "Enable auto-renew", "autorenew_disable_button": "Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", + "payment_methods_manage_button": "💳 Payment Methods", + "payment_methods_title": "💳 Payment Methods", + "payment_method_bind_button": "➕ Add card", + "payment_method_delete_button": "🗑 Remove", + "payment_method_view_button": "ℹ️ Details", + "payment_method_none": "You don't have a saved card yet.", + "payment_method_bound_success": "✅ Card successfully added.", + "payment_method_deleted_success": "✅ Payment method removed.", + "payment_method_delete_confirm": "Remove saved payment method?", + "payment_method_card_title": "💳 Card {network} ••••{last4}", + "payment_method_added_at": "Added: {date}", + "payment_method_last_tx": "Last transaction: {date}", + "payment_method_tx_history_title": "📜 Transactions history", + "payment_method_no_history": "No transactions history.", "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", "subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}", "subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.", diff --git a/locales/ru.json b/locales/ru.json index 53a60b8..d81635f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -374,6 +374,20 @@ "autorenew_enable_button": "Включить автопродление", "autorenew_disable_button": "Выключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", + "payment_methods_manage_button": "💳 Способы оплаты", + "payment_methods_title": "💳 Способы оплаты", + "payment_method_bind_button": "➕ Привязать карту", + "payment_method_delete_button": "🗑 Удалить", + "payment_method_view_button": "ℹ️ Детали", + "payment_method_none": "У вас пока нет сохранённой карты.", + "payment_method_bound_success": "✅ Карта успешно привязана.", + "payment_method_deleted_success": "✅ Способ оплаты удалён.", + "payment_method_delete_confirm": "Удалить сохранённый способ оплаты?", + "payment_method_card_title": "💳 Карта {network} ••••{last4}", + "payment_method_added_at": "Добавлена: {date}", + "payment_method_last_tx": "Последняя операция: {date}", + "payment_method_tx_history_title": "📜 История операций", + "payment_method_no_history": "История операций отсутствует.", "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",