Refactor subscription handling and payment method management
- Updated the user router to import the subscription router directly, preparing for future package separation. - Removed the outdated subscription handler file, streamlining the codebase and improving maintainability. - Introduced a new method for deleting user payment methods by provider ID, enhancing flexibility in payment management. - Improved error handling and clarity in payment method operations, ensuring a smoother user experience.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
|
|
||||||
from . import start
|
from . import start
|
||||||
from . import subscription
|
# TODO: after splitting subscription into a package, replace this import
|
||||||
|
from .subscription import router as subscription_router
|
||||||
from . import referral
|
from . import referral
|
||||||
from . import promo_user
|
from . import promo_user
|
||||||
from . import trial_handler
|
from . import trial_handler
|
||||||
@@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate")
|
|||||||
user_router_aggregate.include_router(promo_user.router)
|
user_router_aggregate.include_router(promo_user.router)
|
||||||
user_router_aggregate.include_router(trial_handler.router)
|
user_router_aggregate.include_router(trial_handler.router)
|
||||||
user_router_aggregate.include_router(start.router)
|
user_router_aggregate.include_router(start.router)
|
||||||
user_router_aggregate.include_router(subscription.router)
|
user_router_aggregate.include_router(subscription_router)
|
||||||
user_router_aggregate.include_router(referral.router)
|
user_router_aggregate.include_router(referral.router)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from . import core
|
||||||
|
from . import payments
|
||||||
|
from . import payment_methods
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_router")
|
||||||
|
|
||||||
|
# Include sub-routers
|
||||||
|
router.include_router(core.router)
|
||||||
|
router.include_router(payments.router)
|
||||||
|
router.include_router(payment_methods.router)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import logging
|
||||||
|
from aiogram import Router, F, types, Bot
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from typing import Optional, Union
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_subscription_options_keyboard,
|
||||||
|
get_back_to_main_menu_markup,
|
||||||
|
)
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
from db.models import Subscription
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_core_router")
|
||||||
|
|
||||||
|
|
||||||
|
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n:
|
||||||
|
err_msg = "Language service error."
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(err_msg, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(event, types.Message):
|
||||||
|
await event.answer(err_msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
|
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||||
|
|
||||||
|
reply_markup = (
|
||||||
|
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||||
|
if settings.subscription_options
|
||||||
|
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
if not target_message_obj:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
|
||||||
|
except Exception:
|
||||||
|
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "main_action:subscribe")
|
||||||
|
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||||
|
await display_subscription_options(callback, i18n_data, settings, session)
|
||||||
|
|
||||||
|
|
||||||
|
async def my_subscription_command_handler(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||||
|
|
||||||
|
if not i18n or not target:
|
||||||
|
if isinstance(event, types.Message):
|
||||||
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not panel_service or not subscription_service:
|
||||||
|
await target.answer(get_text("error_service_unavailable"))
|
||||||
|
return
|
||||||
|
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
text = get_text("subscription_not_active")
|
||||||
|
|
||||||
|
buy_button = InlineKeyboardButton(
|
||||||
|
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||||
|
)
|
||||||
|
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text, reply_markup=kb)
|
||||||
|
except Exception:
|
||||||
|
await event.message.answer(text, reply_markup=kb)
|
||||||
|
else:
|
||||||
|
await event.answer(text, reply_markup=kb)
|
||||||
|
return
|
||||||
|
|
||||||
|
end_date = active.get("end_date")
|
||||||
|
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||||
|
tribute_hint = ""
|
||||||
|
if active.get("status_from_panel", "").lower() == "active":
|
||||||
|
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||||
|
if local_sub:
|
||||||
|
if local_sub.provider == "tribute":
|
||||||
|
link = None
|
||||||
|
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||||
|
tribute_hint = "\n\n" + (
|
||||||
|
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||||
|
)
|
||||||
|
|
||||||
|
text = get_text(
|
||||||
|
"my_subscription_details",
|
||||||
|
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||||
|
days_left=max(0, days_left),
|
||||||
|
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||||
|
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||||
|
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||||
|
traffic_used=(
|
||||||
|
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
kb = base_markup.inline_keyboard
|
||||||
|
try:
|
||||||
|
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||||
|
if 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
|
||||||
|
kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
except Exception:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=target.chat.id,
|
||||||
|
text=text + tribute_hint,
|
||||||
|
reply_markup=markup,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||||
|
async def toggle_autorenew_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, payload = callback.data.split(":", 1)
|
||||||
|
sub_id_str, enable_str = payload.split(":")
|
||||||
|
sub_id = int(sub_id_str)
|
||||||
|
enable = bool(int(enable_str))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = await session.get(Subscription, sub_id)
|
||||||
|
if not sub or sub.user_id != callback.from_user.id:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
if sub.provider == "tribute":
|
||||||
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||||
|
await session.commit()
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("connect"))
|
||||||
|
async def connect_command_handler(
|
||||||
|
message: types.Message,
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||||
|
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
from aiogram import Router, F, types
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_payment_methods_list_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.middlewares.i18n import JsonI18n
|
||||||
|
from db.dal import user_billing_dal
|
||||||
|
from db.models import Payment
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payment_methods_router")
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
get_text = _
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
cards: List[tuple] = []
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or get_text("payment_network_card", default="Card")
|
||||||
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||||
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
for m in methods:
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
|
||||||
|
text = get_text("payment_methods_title")
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + get_text("payment_method_none")
|
||||||
|
|
||||||
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||||
|
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
|
||||||
|
|
||||||
|
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.startswith("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
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||||
|
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("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
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from db.dal.user_billing_dal import (
|
||||||
|
delete_user_payment_method,
|
||||||
|
delete_user_payment_method_by_provider_id,
|
||||||
|
list_user_payment_methods,
|
||||||
|
)
|
||||||
|
if pm_id_raw:
|
||||||
|
if pm_id_raw.isdigit():
|
||||||
|
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
|
||||||
|
else:
|
||||||
|
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
|
||||||
|
try:
|
||||||
|
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||||
|
deleted = deleted or legacy_deleted
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
text = _("payment_methods_title")
|
||||||
|
cards = []
|
||||||
|
for m in methods:
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + _("payment_method_none")
|
||||||
|
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||||
|
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
try:
|
||||||
|
await callback.answer(_("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("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:
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
if not methods:
|
||||||
|
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||||
|
return
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||||
|
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||||
|
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—"
|
||||||
|
last_tx = "—"
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(Payment)
|
||||||
|
.where(
|
||||||
|
Payment.user_id == callback.from_user.id,
|
||||||
|
Payment.status == 'succeeded',
|
||||||
|
Payment.provider == 'yookassa',
|
||||||
|
)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
lp = result.scalar_one_or_none()
|
||||||
|
if lp and lp.created_at:
|
||||||
|
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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(str(sel.method_id), current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||||
|
last_tx = "—"
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(Payment)
|
||||||
|
.where(
|
||||||
|
Payment.user_id == callback.from_user.id,
|
||||||
|
Payment.status == 'succeeded',
|
||||||
|
Payment.provider == 'yookassa',
|
||||||
|
)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
last_payment = result.scalar_one_or_none()
|
||||||
|
if last_payment and last_payment.created_at:
|
||||||
|
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||||
|
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(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:history"))
|
||||||
|
async def payment_method_history(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
|
||||||
|
|
||||||
|
from db.dal import payment_dal
|
||||||
|
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||||
|
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||||
|
|
||||||
|
selected_pm_provider_id: Optional[str] = None
|
||||||
|
pm_filter_requested: bool = False
|
||||||
|
try:
|
||||||
|
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||||
|
if split_pm_id:
|
||||||
|
pm_filter_requested = True
|
||||||
|
if split_pm_id.isdigit():
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||||
|
if sel and sel.provider_payment_method_id:
|
||||||
|
selected_pm_provider_id = sel.provider_payment_method_id
|
||||||
|
else:
|
||||||
|
selected_pm_provider_id = split_pm_id
|
||||||
|
except Exception:
|
||||||
|
selected_pm_provider_id = None
|
||||||
|
pm_filter_requested = False
|
||||||
|
|
||||||
|
if pm_filter_requested and not selected_pm_provider_id:
|
||||||
|
user_payments = []
|
||||||
|
|
||||||
|
if selected_pm_provider_id:
|
||||||
|
filtered: List[Payment] = []
|
||||||
|
for p in user_payments:
|
||||||
|
if p.provider != 'yookassa':
|
||||||
|
continue
|
||||||
|
if p.yookassa_payment_id and yookassa_service:
|
||||||
|
try:
|
||||||
|
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||||
|
pm = (info or {}).get("payment_method") or {}
|
||||||
|
if pm.get("id") == selected_pm_provider_id:
|
||||||
|
filtered.append(p)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user_payments = filtered
|
||||||
|
|
||||||
|
if not user_payments:
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||||
|
back_pm_id = ""
|
||||||
|
try:
|
||||||
|
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||||
|
except Exception:
|
||||||
|
back_pm_id = ""
|
||||||
|
back_markup = (
|
||||||
|
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||||
|
if back_pm_id
|
||||||
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _format_item(p: Payment) -> str:
|
||||||
|
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||||
|
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||||
|
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||||
|
|
||||||
|
lines = [_format_item(p) for p in user_payments]
|
||||||
|
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||||
|
try:
|
||||||
|
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||||
|
except Exception:
|
||||||
|
split_pm_id_for_back = ""
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||||
|
back_markup = (
|
||||||
|
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||||
|
if split_pm_id_for_back
|
||||||
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:list:"))
|
||||||
|
async def payment_methods_list(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")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
cards: List[tuple] = []
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
for m in methods:
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or get_text("payment_network_card", default="Card")
|
||||||
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||||
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, _, page_str = callback.data.split(":", 2)
|
||||||
|
page = int(page_str)
|
||||||
|
except Exception:
|
||||||
|
page = 0
|
||||||
|
|
||||||
|
text = get_text("payment_methods_title")
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + get_text("payment_method_none")
|
||||||
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import logging
|
||||||
|
from aiogram import Router, F, types
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
|
||||||
|
from bot.services.yookassa_service import YooKassaService
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from db.dal import payment_dal, user_billing_dal
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payments_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||||
|
async def select_subscription_period_callback_handler(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")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
months = int(callback.data.split(":")[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
price_rub = settings.subscription_options.get(months)
|
||||||
|
if price_rub is None:
|
||||||
|
logging.error(
|
||||||
|
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
|
text_content = get_text("choose_payment_method")
|
||||||
|
tribute_url = settings.tribute_payment_links.get(months)
|
||||||
|
stars_price = settings.stars_subscription_options.get(months)
|
||||||
|
reply_markup = get_payment_method_keyboard(
|
||||||
|
months,
|
||||||
|
price_rub,
|
||||||
|
tribute_url,
|
||||||
|
stars_price,
|
||||||
|
currency_symbol_val,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(
|
||||||
|
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||||
|
)
|
||||||
|
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||||
|
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not yookassa_service or not yookassa_service.configured:
|
||||||
|
logging.error("YooKassa service is not configured or unavailable.")
|
||||||
|
target_msg_edit = callback.message
|
||||||
|
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, data_payload = callback.data.split(":", 1)
|
||||||
|
months_str, price_str = data_payload.split(":")
|
||||||
|
months = int(months_str)
|
||||||
|
price_rub = float(price_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
payment_description = get_text("payment_description_subscription", months=months)
|
||||||
|
currency_code_for_yk = "RUB"
|
||||||
|
|
||||||
|
payment_record_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": price_rub,
|
||||||
|
"currency": currency_code_for_yk,
|
||||||
|
"status": "pending_yookassa",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": months,
|
||||||
|
}
|
||||||
|
|
||||||
|
db_payment_record = None
|
||||||
|
try:
|
||||||
|
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||||
|
await session.commit()
|
||||||
|
logging.info(
|
||||||
|
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||||
|
)
|
||||||
|
except Exception as e_db_payment:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not db_payment_record:
|
||||||
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
yookassa_metadata = {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"subscription_months": str(months),
|
||||||
|
"payment_db_id": str(db_payment_record.payment_id),
|
||||||
|
}
|
||||||
|
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||||
|
|
||||||
|
payment_response_yk = await yookassa_service.create_payment(
|
||||||
|
amount=price_rub,
|
||||||
|
currency=currency_code_for_yk,
|
||||||
|
description=payment_description,
|
||||||
|
metadata=yookassa_metadata,
|
||||||
|
receipt_email=receipt_email_for_yk,
|
||||||
|
save_payment_method=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||||
|
pm = payment_response_yk.get("payment_method")
|
||||||
|
try:
|
||||||
|
if pm and pm.get("id"):
|
||||||
|
pm_type = pm.get("type")
|
||||||
|
title = pm.get("title")
|
||||||
|
card = pm.get("card") or {}
|
||||||
|
account_number = pm.get("account_number") or pm.get("account")
|
||||||
|
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||||
|
display_network = card.get("card_type") or title or "Card"
|
||||||
|
display_last4 = card.get("last4")
|
||||||
|
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||||
|
display_network = "YooMoney"
|
||||||
|
display_last4 = (
|
||||||
|
account_number[-4:]
|
||||||
|
if isinstance(account_number, str) and len(account_number) >= 4
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||||
|
display_last4 = None
|
||||||
|
await user_billing_dal.upsert_yk_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
payment_method_id=pm["id"],
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await user_billing_dal.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=pm["id"],
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
set_default=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_db_id=db_payment_record.payment_id,
|
||||||
|
new_status=payment_response_yk.get("status", "pending"),
|
||||||
|
yk_payment_id=payment_response_yk.get("id"),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_update_ykid:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(key="payment_link_message", months=months),
|
||||||
|
reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation")
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_db_fail_create:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -140,3 +140,25 @@ async def delete_user_payment_method(session: AsyncSession, user_id: int, method
|
|||||||
await session.delete(method)
|
await session.delete(method)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_payment_method_by_provider_id(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
provider_payment_method_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a saved payment method by its provider payment_method.id for a specific user.
|
||||||
|
|
||||||
|
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
|
||||||
|
"""
|
||||||
|
stmt = select(UserPaymentMethod).where(
|
||||||
|
UserPaymentMethod.user_id == user_id,
|
||||||
|
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||||
|
if not method:
|
||||||
|
return False
|
||||||
|
await session.delete(method)
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user