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"),
|
||||
)
|
||||
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
|
||||
try:
|
||||
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 datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from config.settings import Settings
|
||||
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_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)
|
||||
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 db.dal import user_billing_dal
|
||||
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.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
from db.models import Subscription, Payment
|
||||
|
||||
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")
|
||||
_ = 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)
|
||||
has_card = bool(billing and billing.yookassa_payment_method_id)
|
||||
text = _("payment_methods_title")
|
||||
if not has_card:
|
||||
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))
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -639,23 +643,25 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
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):
|
||||
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))
|
||||
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:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
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):
|
||||
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
|
||||
# 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)
|
||||
await session.commit()
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
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)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||
# 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)
|
||||
return
|
||||
pm_id = callback.data.split(":", 1)[-1] if ":" in callback.data else str(methods[0].method_id)
|
||||
# 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 = "—"
|
||||
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 "—"
|
||||
# Placeholder for last tx; real data requires querying payments
|
||||
# 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 "????")
|
||||
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:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
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):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
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:
|
||||
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
|
||||
]
|
||||
# Show subscription purchase titles instead of raw provider/status
|
||||
def _format_item(p):
|
||||
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)
|
||||
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()
|
||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Dict, Optional, List
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -232,13 +232,13 @@ def get_connect_and_main_keyboard(
|
||||
|
||||
|
||||
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)
|
||||
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"),
|
||||
)
|
||||
# Route to the new list view
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_methods_title"), callback_data="pm:list:0")
|
||||
)
|
||||
builder.row(
|
||||
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()
|
||||
|
||||
|
||||
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)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data="pm:delete"),
|
||||
InlineKeyboardButton(text=_(key="cancel_button"), callback_data="pm:manage"),
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
|
||||
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
|
||||
)
|
||||
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)
|
||||
builder = InlineKeyboardBuilder()
|
||||
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(
|
||||
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()
|
||||
|
||||
@@ -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.adjust(1)
|
||||
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
|
||||
return True
|
||||
|
||||
billing = await user_billing_dal.get_user_billing(session, sub.user_id)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
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}")
|
||||
return False
|
||||
|
||||
@@ -823,7 +824,7 @@ class SubscriptionService:
|
||||
currency="RUB",
|
||||
description=f"Auto-renewal for {months} months",
|
||||
metadata=metadata,
|
||||
payment_method_id=billing.yookassa_payment_method_id,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
save_payment_method=False,
|
||||
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 import select, update
|
||||
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]:
|
||||
@@ -52,3 +52,91 @@ async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
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")
|
||||
|
||||
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):
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"payment_method_last_tx": "Last transaction: {date}",
|
||||
"payment_method_tx_history_title": "📜 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_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.",
|
||||
|
||||
@@ -388,6 +388,7 @@
|
||||
"payment_method_last_tx": "Последняя операция: {date}",
|
||||
"payment_method_tx_history_title": "📜 История операций",
|
||||
"payment_method_no_history": "История операций отсутствует.",
|
||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||
|
||||
Reference in New Issue
Block a user