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.
This commit is contained in:
@@ -10,7 +10,9 @@ from config.settings import Settings
|
|||||||
from db.dal import payment_dal
|
from db.dal import payment_dal
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
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 bot.services.yookassa_service import YooKassaService
|
||||||
from db.dal import user_billing_dal
|
from db.dal import user_billing_dal
|
||||||
from bot.services.stars_service import StarsService
|
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.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
|
from bot.services.yookassa_service import YooKassaService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
@@ -523,13 +526,15 @@ async def my_subscription_command_handler(
|
|||||||
else get_text("traffic_na")
|
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)
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
kb = base_markup.inline_keyboard
|
kb = base_markup.inline_keyboard
|
||||||
try:
|
try:
|
||||||
if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute':
|
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")
|
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
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
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)
|
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()
|
@router.pre_checkout_query()
|
||||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
||||||
await pre_checkout_query.answer(ok=True)
|
await pre_checkout_query.answer(ok=True)
|
||||||
|
|||||||
@@ -229,3 +229,51 @@ def get_connect_and_main_keyboard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return builder.as_markup()
|
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()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
from yookassa import Configuration, Payment as YooKassaPayment
|
from yookassa import Configuration, Payment as YooKassaPayment
|
||||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||||
from yookassa.domain.common.confirmation_type import ConfirmationType
|
from yookassa.domain.common.confirmation_type import ConfirmationType
|
||||||
|
from yookassa.domain.models.payment_data import PaymentMethodType
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@ class YooKassaService:
|
|||||||
receipt_phone: Optional[str] = None,
|
receipt_phone: Optional[str] = None,
|
||||||
save_payment_method: bool = False,
|
save_payment_method: bool = False,
|
||||||
payment_method_id: Optional[str] = None,
|
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:
|
if not self.configured:
|
||||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||||
return None
|
return None
|
||||||
@@ -105,6 +107,10 @@ class YooKassaService:
|
|||||||
"value": str(round(amount, 2)),
|
"value": str(round(amount, 2)),
|
||||||
"currency": currency.upper()
|
"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_capture(capture)
|
||||||
builder.set_confirmation({
|
builder.set_confirmation({
|
||||||
"type": ConfirmationType.REDIRECT,
|
"type": ConfirmationType.REDIRECT,
|
||||||
|
|||||||
@@ -39,3 +39,16 @@ async def upsert_yk_payment_method(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(record)
|
await session.refresh(record)
|
||||||
return 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
|
||||||
|
|||||||
@@ -375,6 +375,20 @@
|
|||||||
"autorenew_enable_button": "Enable auto-renew",
|
"autorenew_enable_button": "Enable auto-renew",
|
||||||
"autorenew_disable_button": "Disable auto-renew",
|
"autorenew_disable_button": "Disable auto-renew",
|
||||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||||
|
"payment_methods_manage_button": "💳 Payment Methods",
|
||||||
|
"payment_methods_title": "💳 <b>Payment Methods</b>",
|
||||||
|
"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": "Paid via Tribute. Renew using your Tribute link.",
|
||||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {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.",
|
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||||
|
|||||||
@@ -374,6 +374,20 @@
|
|||||||
"autorenew_enable_button": "Включить автопродление",
|
"autorenew_enable_button": "Включить автопродление",
|
||||||
"autorenew_disable_button": "Выключить автопродление",
|
"autorenew_disable_button": "Выключить автопродление",
|
||||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||||
|
"payment_methods_manage_button": "💳 Способы оплаты",
|
||||||
|
"payment_methods_title": "💳 <b>Способы оплаты</b>",
|
||||||
|
"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": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||||
|
|||||||
Reference in New Issue
Block a user