From 6b5828ea51e981ad643ef1705168439aca54e6c6 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:11:18 +0300 Subject: [PATCH] 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. --- bot/handlers/user/payment.py | 31 +++++++ bot/handlers/user/subscription.py | 120 ++++++++++++++++++++++--- bot/keyboards/inline/user_keyboards.py | 71 ++++++++++++--- bot/services/subscription_service.py | 7 +- db/dal/user_billing_dal.py | 92 ++++++++++++++++++- db/models.py | 18 ++++ locales/en.json | 1 + locales/ru.json | 1 + 8 files changed, 310 insertions(+), 31 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 83534b2..69b65f2 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -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') diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index fd27b2e..f168f02 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -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) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 4aa5448..3bc958c 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -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() diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 206eea7..4165cc6 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -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, ) diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 78d80a5..319be82 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -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 diff --git a/db/models.py b/db/models.py index cc36312..8f661e6 100644 --- a/db/models.py +++ b/db/models.py @@ -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" diff --git a/locales/en.json b/locales/en.json index 31619e9..a171d61 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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.", diff --git a/locales/ru.json b/locales/ru.json index d81635f..d6af159 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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.",