Implement multi-card payment method management and enhance user notifications
- Added support for saving multiple payment methods, allowing users to bind and manage their cards effectively. - Introduced a new paginated list view for displaying saved payment methods, improving user experience. - Enhanced user notifications for successful binding of payment methods, including localized messages. - Updated the database models and data access layer to accommodate multi-card functionality. - Refactored existing payment method handlers to integrate with the new multi-card system.
This commit is contained in:
@@ -471,6 +471,37 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
card_network=card.get("card_type"),
|
card_network=card.get("card_type"),
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
# Save multi-card entry and mark default if first
|
||||||
|
try:
|
||||||
|
from db.dal import user_billing_dal as ub
|
||||||
|
await ub.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=payment_method.get("id"),
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=card.get("last4"),
|
||||||
|
card_network=card.get("card_type"),
|
||||||
|
set_default=True,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
# Notify user about successful binding with Back button
|
||||||
|
try:
|
||||||
|
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||||
|
from db.dal import user_dal
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if db_user and db_user.language_code:
|
||||||
|
i18n_lang = db_user.language_code
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=user_id,
|
||||||
|
text=_("payment_method_bound_success"),
|
||||||
|
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Attempt to cancel the authorization to avoid charge hold
|
# Attempt to cancel the authorization to avoid charge hold
|
||||||
try:
|
try:
|
||||||
yk: YooKassaService = request.app.get('yookassa_service')
|
yk: YooKassaService = request.app.get('yookassa_service')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPri
|
|||||||
from typing import Optional, Dict, Any, Union
|
from typing import Optional, Dict, Any, Union
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal
|
from db.dal import payment_dal
|
||||||
@@ -12,7 +13,8 @@ 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_methods_manage_keyboard, get_payment_method_delete_confirm_keyboard,
|
||||||
get_payment_method_details_keyboard, get_bind_url_keyboard)
|
get_payment_method_details_keyboard, get_bind_url_keyboard,
|
||||||
|
get_payment_methods_list_keyboard, get_back_to_payment_methods_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
|
||||||
@@ -23,7 +25,7 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.yookassa_service import YooKassaService
|
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, Payment
|
||||||
|
|
||||||
router = Router(name="user_subscription_router")
|
router = Router(name="user_subscription_router")
|
||||||
|
|
||||||
@@ -596,11 +598,13 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
|||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
# New list view relies on multi-card; but keep has_card for legacy text
|
||||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||||
has_card = bool(billing and billing.yookassa_payment_method_id)
|
has_card = bool(billing and billing.yookassa_payment_method_id)
|
||||||
text = _("payment_methods_title")
|
text = _("payment_methods_title")
|
||||||
if not has_card:
|
if not has_card:
|
||||||
text += "\n\n" + _("payment_method_none")
|
text += "\n\n" + _("payment_method_none")
|
||||||
|
# Redirect users to the new paginated list
|
||||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card))
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card))
|
||||||
try:
|
try:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -639,23 +643,25 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pm:delete_confirm")
|
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = 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))
|
pm_id = callback.data.split(":", 1)[-1] if ":" in callback.data else ""
|
||||||
|
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||||
try:
|
try:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pm:delete")
|
@router.callback_query(F.data.startswith("pm:delete"))
|
||||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
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)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
# Single-card storage: ignore pm_id for now but retain for future multi-card
|
||||||
deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||||
@@ -666,7 +672,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pm:view")
|
@router.callback_query(F.data.startswith("pm:view"))
|
||||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
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)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -674,21 +680,73 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
|||||||
|
|
||||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||||
if not billing or not billing.yookassa_payment_method_id:
|
if not billing or not billing.yookassa_payment_method_id:
|
||||||
|
# Try multi-card records
|
||||||
|
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)
|
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||||
return
|
return
|
||||||
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
pm_id = callback.data.split(":", 1)[-1] if ":" in callback.data else str(methods[0].method_id)
|
||||||
# Placeholder for last tx; real data requires querying payments
|
# Map:
|
||||||
|
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
|
||||||
|
title = _("payment_method_card_title", network=sel.card_network or "Card", last4=sel.card_last4 or "????")
|
||||||
|
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—"
|
||||||
|
# Last tx
|
||||||
last_tx = "—"
|
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 transaction lookup (latest succeeded YooKassa payment by user)
|
||||||
|
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
|
||||||
title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4 or "????")
|
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)}"
|
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))
|
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||||
try:
|
try:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "pm:history")
|
@router.callback_query(F.data.startswith("pm:history"))
|
||||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
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)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -701,14 +759,48 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
|||||||
if not user_payments:
|
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))
|
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True))
|
||||||
return
|
return
|
||||||
lines = [
|
# Show subscription purchase titles instead of raw provider/status
|
||||||
f"{p.created_at.strftime('%Y-%m-%d')} — {p.amount} {p.currency} — {p.provider} — {p.status}"
|
def _format_item(p):
|
||||||
for p in user_payments
|
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)
|
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))
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True))
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
# For now we only support single saved YK method; format as list API-ready
|
||||||
|
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:
|
||||||
|
title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4 or "????")
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
|
||||||
|
# Parse page
|
||||||
|
try:
|
||||||
|
_, _, page_str = callback.data.split(":", 2)
|
||||||
|
page = int(page_str)
|
||||||
|
except Exception:
|
||||||
|
page = 0
|
||||||
|
|
||||||
|
text = _("payment_methods_title")
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + _("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
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List, Tuple
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
@@ -232,12 +232,12 @@ def get_connect_and_main_keyboard(
|
|||||||
|
|
||||||
|
|
||||||
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
|
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
|
||||||
|
"""Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
if has_card:
|
# Route to the new list view
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="payment_method_view_button"), callback_data="pm:view"),
|
InlineKeyboardButton(text=_(key="payment_methods_title"), callback_data="pm:list:0")
|
||||||
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data="pm:delete_confirm"),
|
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
|
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
|
||||||
@@ -248,24 +248,64 @@ def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_payment_method_delete_confirm_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
def get_payment_methods_list_keyboard(
|
||||||
|
cards: List[Tuple[str, str]],
|
||||||
|
page: int,
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Build a paginated list of saved payment methods.
|
||||||
|
cards: list of tuples (payment_method_id, display_title)
|
||||||
|
page: 0-based page index
|
||||||
|
"""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
per_page = 5
|
||||||
|
total = len(cards)
|
||||||
|
start = page * per_page
|
||||||
|
end = start + per_page
|
||||||
|
for pm_id, title in cards[start:end]:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=title, callback_data=f"pm:view:{pm_id}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pagination controls if needed
|
||||||
|
nav_buttons: List[InlineKeyboardButton] = []
|
||||||
|
if start > 0:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"pm:list:{page-1}"))
|
||||||
|
if end < total:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"pm:list:{page+1}"))
|
||||||
|
if nav_buttons:
|
||||||
|
builder.row(*nav_buttons)
|
||||||
|
|
||||||
|
# Bind new card and back
|
||||||
|
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(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data="pm:delete"),
|
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
|
||||||
InlineKeyboardButton(text=_(key="cancel_button"), callback_data="pm:manage"),
|
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
|
||||||
)
|
)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_payment_method_details_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data="pm:history")
|
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data=f"pm:history:{pm_id}")
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
|
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data=f"pm:delete_confirm:{pm_id}")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=_(key="payment_methods_title"), callback_data="pm:list:0")
|
||||||
)
|
)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
@@ -277,3 +317,10 @@ def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyb
|
|||||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
|
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=_(key="payment_methods_title"), callback_data="pm:list:0"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|||||||
@@ -793,8 +793,9 @@ class SubscriptionService:
|
|||||||
# Tribute is paid externally; we do not auto-charge here
|
# Tribute is paid externally; we do not auto-charge here
|
||||||
return True
|
return True
|
||||||
|
|
||||||
billing = await user_billing_dal.get_user_billing(session, sub.user_id)
|
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||||
if not billing or not billing.yookassa_payment_method_id:
|
default_pm = await get_user_default_payment_method(session, sub.user_id)
|
||||||
|
if not default_pm:
|
||||||
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
|
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -823,7 +824,7 @@ class SubscriptionService:
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
description=f"Auto-renewal for {months} months",
|
description=f"Auto-renewal for {months} months",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
payment_method_id=billing.yookassa_payment_method_id,
|
payment_method_id=default_pm.provider_payment_method_id,
|
||||||
save_payment_method=False,
|
save_payment_method=False,
|
||||||
capture=True,
|
capture=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from db.models import UserBilling
|
from db.models import UserBilling, UserPaymentMethod
|
||||||
|
|
||||||
|
|
||||||
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
|
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
|
||||||
@@ -52,3 +52,91 @@ async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(existing)
|
await session.refresh(existing)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-card support API
|
||||||
|
async def upsert_user_payment_method(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
provider_payment_method_id: str,
|
||||||
|
provider: str = "yookassa",
|
||||||
|
card_last4: Optional[str] = None,
|
||||||
|
card_network: Optional[str] = None,
|
||||||
|
set_default: bool = False,
|
||||||
|
) -> UserPaymentMethod:
|
||||||
|
existing_stmt = select(UserPaymentMethod).where(UserPaymentMethod.provider_payment_method_id == provider_payment_method_id)
|
||||||
|
result = await session.execute(existing_stmt)
|
||||||
|
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
existing.card_last4 = card_last4
|
||||||
|
existing.card_network = card_network
|
||||||
|
if set_default:
|
||||||
|
# unset previous defaults
|
||||||
|
await session.execute(
|
||||||
|
update(UserPaymentMethod)
|
||||||
|
.where(UserPaymentMethod.user_id == user_id)
|
||||||
|
.values(is_default=False)
|
||||||
|
)
|
||||||
|
existing.is_default = True
|
||||||
|
existing.updated_at = func.now()
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
if set_default:
|
||||||
|
await session.execute(
|
||||||
|
update(UserPaymentMethod)
|
||||||
|
.where(UserPaymentMethod.user_id == user_id)
|
||||||
|
.values(is_default=False)
|
||||||
|
)
|
||||||
|
record = UserPaymentMethod(
|
||||||
|
user_id=user_id,
|
||||||
|
provider=provider,
|
||||||
|
provider_payment_method_id=provider_payment_method_id,
|
||||||
|
card_last4=card_last4,
|
||||||
|
card_network=card_network,
|
||||||
|
is_default=set_default,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def list_user_payment_methods(session: AsyncSession, user_id: int, provider: Optional[str] = None) -> List[UserPaymentMethod]:
|
||||||
|
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||||
|
if provider:
|
||||||
|
stmt = stmt.where(UserPaymentMethod.provider == provider)
|
||||||
|
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_default_payment_method(session: AsyncSession, user_id: int, provider: str = "yookassa") -> Optional[UserPaymentMethod]:
|
||||||
|
stmt = select(UserPaymentMethod).where(
|
||||||
|
UserPaymentMethod.user_id == user_id,
|
||||||
|
UserPaymentMethod.provider == provider,
|
||||||
|
UserPaymentMethod.is_default == True,
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_default_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||||
|
methods = await list_user_payment_methods(session, user_id)
|
||||||
|
if not any(m.method_id == method_id for m in methods):
|
||||||
|
return False
|
||||||
|
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id).values(is_default=False))
|
||||||
|
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id).values(is_default=True))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||||
|
stmt = select(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
method = result.scalar_one_or_none()
|
||||||
|
if not method:
|
||||||
|
return False
|
||||||
|
await session.delete(method)
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -126,6 +126,24 @@ class UserBilling(Base):
|
|||||||
|
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|
||||||
|
class UserPaymentMethod(Base):
|
||||||
|
__tablename__ = "user_payment_methods"
|
||||||
|
|
||||||
|
method_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||||
|
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||||
|
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
card_last4 = Column(String, nullable=True)
|
||||||
|
card_network = Column(String, nullable=True)
|
||||||
|
is_default = Column(Boolean, default=False, index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('user_id', 'provider_payment_method_id', name='uq_user_provider_method'),
|
||||||
|
)
|
||||||
|
|
||||||
class PromoCode(Base):
|
class PromoCode(Base):
|
||||||
__tablename__ = "promo_codes"
|
__tablename__ = "promo_codes"
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,7 @@
|
|||||||
"payment_method_last_tx": "Last transaction: {date}",
|
"payment_method_last_tx": "Last transaction: {date}",
|
||||||
"payment_method_tx_history_title": "📜 Transactions history",
|
"payment_method_tx_history_title": "📜 Transactions history",
|
||||||
"payment_method_no_history": "No transactions history.",
|
"payment_method_no_history": "No transactions history.",
|
||||||
|
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -388,6 +388,7 @@
|
|||||||
"payment_method_last_tx": "Последняя операция: {date}",
|
"payment_method_last_tx": "Последняя операция: {date}",
|
||||||
"payment_method_tx_history_title": "📜 История операций",
|
"payment_method_tx_history_title": "📜 История операций",
|
||||||
"payment_method_no_history": "История операций отсутствует.",
|
"payment_method_no_history": "История операций отсутствует.",
|
||||||
|
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||||
"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