From d6f707d386aa69c3679133292bdab4002d50dba4 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 09:40:42 +0300 Subject: [PATCH 01/41] Implement auto-renew subscription feature and enhance payment method handling - Added a recurring billing task to automatically charge users one day before subscription expiry, improving subscription management. - Introduced a new UserBilling model to store saved payment methods for off-session charges, enhancing user experience. - Updated YooKassa service to support saving payment methods and capturing payments for auto-renewals. - Enhanced subscription handling to toggle auto-renew settings and provide user feedback through localized messages. - Improved error handling and logging for payment method persistence and subscription renewal processes. --- bot/handlers/user/payment.py | 19 +++++- bot/handlers/user/subscription.py | 94 ++++++++++++++++++++++++++-- bot/main_bot.py | 31 +++++++++ bot/services/subscription_service.py | 54 +++++++++++++++- bot/services/yookassa_service.py | 16 ++++- db/dal/subscription_dal.py | 5 ++ db/dal/user_billing_dal.py | 41 ++++++++++++ db/models.py | 14 +++++ docker-compose.yml | 8 +++ locales/en.json | 6 ++ locales/ru.json | 6 ++ 11 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 db/dal/user_billing_dal.py diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 050ace8..1d6c00a 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker from yookassa.domain.notification import WebhookNotification from yookassa.domain.models.amount import Amount as YooKassaAmount -from db.dal import payment_dal, user_dal +from db.dal import payment_dal, user_dal, user_billing_dal from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService @@ -89,6 +89,21 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, try: yk_payment_id_from_hook = payment_info_from_webhook.get("id") + # Try to capture and save payment method for future charges if available + try: + payment_method = payment_info_from_webhook.get("payment_method") + if isinstance(payment_method, dict) and payment_method.get("saved", False): + pm_id = payment_method.get("id") + card = payment_method.get("card") or {} + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm_id, + card_last4=card.get("last4"), + card_network=card.get("card_type"), + ) + except Exception: + logging.exception("Failed to persist YooKassa payment method from webhook") updated_payment_record = await payment_dal.update_payment_status_by_db_id( session, payment_db_id=payment_db_id, @@ -329,6 +344,8 @@ async def yookassa_webhook_route(request: web.Request): "description": str(payment_data_from_notification.description) if payment_data_from_notification.description else None, + "payment_method": payment_data_from_notification.payment_method.to_dict() + if getattr(payment_data_from_notification, 'payment_method', None) else None, } async with payment_processing_lock: diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 433169c..0202757 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -12,12 +12,14 @@ 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) from bot.services.yookassa_service import YooKassaService +from db.dal import user_billing_dal from bot.services.stars_service import StarsService from bot.services.crypto_pay_service import CryptoPayService from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService from bot.middlewares.i18n import JsonI18n +from db.dal import subscription_dal router = Router(name="user_subscription_router") @@ -297,9 +299,25 @@ async def pay_yk_callback_handler( currency=currency_code_for_yk, description=payment_description, metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk) + receipt_email=receipt_email_for_yk, + save_payment_method=True) if payment_response_yk and payment_response_yk.get("confirmation_url"): + # If YooKassa already provided a payment_method (rare on redirect), store it + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get('id'): + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm['id'], + card_last4=pm.get('last4'), + card_network=pm.get('card', {}).get('card_type') if isinstance(pm.get('card'), dict) else None, + ) + 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, @@ -469,6 +487,24 @@ async def my_subscription_command_handler( (end_date.date() - datetime.now().date()).days if end_date else 0 ) + # Auto-renew toggle hint and Tribute notice + tribute_hint = "" + if active.get("status_from_panel", "").lower() == "active": + # Try to infer provider; fetch local sub for flags + # NOTE: Lightweight lookup by user_id + local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) + auto_renew_state = None + if local_sub: + auto_renew_state = local_sub.auto_renew_enabled + 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) + if link: + tribute_hint = "\n\n" + get_text("subscription_tribute_notice_with_link", link=link) + else: + tribute_hint = "\n\n" + 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", @@ -486,7 +522,16 @@ async def my_subscription_command_handler( else get_text("traffic_na") ) ) - markup = get_back_to_main_menu_markup(current_lang, i18n) + # Build markup with auto-renew toggle if available + base_markup = get_back_to_main_menu_markup(current_lang, i18n) + kb = base_markup.inline_keyboard + try: + if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': + toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") + 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 + except Exception: + pass + markup = InlineKeyboardMarkup(inline_keyboard=kb) if isinstance(event, types.CallbackQuery): try: @@ -494,11 +539,50 @@ async def my_subscription_command_handler( except Exception: pass try: - await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) except: - await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + 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, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + 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(type(subscription_service).__annotations__.get('sub', Subscription), sub_id) # fallback avoids import cycle + # Better: direct DAL fetch + 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 + # Refresh panel info screen + await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) @router.pre_checkout_query() diff --git a/bot/main_bot.py b/bot/main_bot.py index 86981b8..3f1f11b 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -293,6 +293,37 @@ async def run_bot(settings_param: Settings): main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + async def recurring_billing_task(): + # Run periodic check to bill 1 day before expiry + async_session_factory = dp.get("async_session_factory") + subscription_service = dp.get("subscription_service") + if not async_session_factory or not subscription_service: + logging.warning("Recurring billing task: dependencies missing; task not started") + return + while True: + try: + async with async_session_factory() as session: + # Find subscriptions ending in 1 day + subs = await subscription_service.get_subscriptions_ending_soon(session, 1) + # We need actual Subscription objects; reuse DAL directly + from db.dal import subscription_dal + subs_models = await subscription_dal.get_subscriptions_near_expiration(session, 1) + handled = 0 + for sub in subs_models: + try: + ok = await subscription_service.charge_subscription_renewal(session, sub) + handled += 1 if ok else 0 + except Exception: + logging.exception("Auto-renew attempt failed") + if handled: + await session.commit() + except Exception: + logging.exception("Recurring billing iteration failed") + # Sleep 1 hour between scans + await asyncio.sleep(3600) + + main_tasks.append(asyncio.create_task(recurring_billing_task(), name="RecurringBillingTask")) + logging.info("Starting bot in Webhook mode with AIOHTTP server...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 78cf15a..3fb6c46 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple from aiogram import Bot from bot.middlewares.i18n import JsonI18n -from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal +from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal from bot.utils.date_utils import add_months from db.models import User, Subscription @@ -780,6 +780,58 @@ class SubscriptionService: ) return results + async def charge_subscription_renewal( + self, + session: AsyncSession, + sub: Subscription, + ) -> bool: + """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" + if not sub.auto_renew_enabled: + return True + if sub.provider == "tribute": + # 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: + logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") + return False + + try: + from .yookassa_service import YooKassaService # local import to avoid cycles + yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] + except Exception: + yk = None # type: ignore + if not yk or not getattr(yk, 'configured', False): + logging.warning("YooKassa unavailable for auto-renew") + return False + + months = sub.duration_months or 1 + amount = self.settings.subscription_options.get(months) + if not amount: + logging.error(f"Auto-renew price missing for {months} months") + return False + + metadata = { + "user_id": str(sub.user_id), + "auto_renew_for_subscription_id": str(sub.subscription_id), + "subscription_months": str(months), + } + resp = await yk.create_payment( + amount=float(amount), + currency="RUB", + description=f"Auto-renewal for {months} months", + metadata=metadata, + payment_method_id=billing.yookassa_payment_method_id, + save_payment_method=False, + capture=True, + ) + if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}: + logging.error(f"Auto-renew create_payment failed: {resp}") + return False + logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") + return True + async def update_last_notification_sent( self, session: AsyncSession, user_id: int, subscription_end_date: datetime ): diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 43aaeae..1d98734 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -61,7 +61,10 @@ class YooKassaService: description: str, metadata: Dict[str, Any], receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]: + receipt_phone: Optional[str] = None, + save_payment_method: bool = False, + payment_method_id: Optional[str] = None, + capture: bool = True) -> Optional[Dict[str, Any]]: if not self.configured: logging.error("YooKassa is not configured. Cannot create payment.") return None @@ -102,13 +105,19 @@ class YooKassaService: "value": str(round(amount, 2)), "currency": currency.upper() }) - builder.set_capture(True) + builder.set_capture(capture) builder.set_confirmation({ "type": ConfirmationType.REDIRECT, "return_url": self.return_url }) builder.set_description(description) builder.set_metadata(metadata) + if save_payment_method: + # Ask YooKassa to save method for off-session charges + builder.set_save_payment_method(True) + if payment_method_id: + # Use a previously saved payment method for merchant-initiated payments + builder.set_payment_method_id(payment_method_id) receipt_items_list: List[Dict[str, Any]] = [{ "description": @@ -178,7 +187,8 @@ class YooKassaService: "description_from_yk": response.description, "test_mode": - response.test if hasattr(response, 'test') else None + response.test if hasattr(response, 'test') else None, + "payment_method": getattr(response, 'payment_method', None), } except Exception as e: logging.error(f"YooKassa payment creation failed: {e}", diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 6a0b0e6..1785c82 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -53,6 +53,11 @@ async def update_subscription( return sub +async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]: + """Toggle auto_renew_enabled for a subscription.""" + return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled}) + + async def set_user_subscriptions_cancelled_with_grace( session: AsyncSession, user_id: int, grace_days: int = 1) -> int: """Mark all active user subscriptions as cancelled with a short grace period. diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py new file mode 100644 index 0000000..597fd75 --- /dev/null +++ b/db/dal/user_billing_dal.py @@ -0,0 +1,41 @@ +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from sqlalchemy.sql import func + +from db.models import UserBilling + + +async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]: + stmt = select(UserBilling).where(UserBilling.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def upsert_yk_payment_method( + session: AsyncSession, + *, + user_id: int, + payment_method_id: str, + card_last4: Optional[str] = None, + card_network: Optional[str] = None, +) -> UserBilling: + existing = await get_user_billing(session, user_id) + if existing: + existing.yookassa_payment_method_id = payment_method_id + existing.card_last4 = card_last4 + existing.card_network = card_network + existing.updated_at = func.now() + await session.flush() + await session.refresh(existing) + return existing + record = UserBilling( + user_id=user_id, + yookassa_payment_method_id=payment_method_id, + card_last4=card_last4, + card_network=card_network, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record diff --git a/db/models.py b/db/models.py index c8e2c4a..c53e23a 100644 --- a/db/models.py +++ b/db/models.py @@ -72,6 +72,7 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) + auto_renew_enabled = Column(Boolean, default=False, index=True) user = relationship("User", back_populates="subscriptions") @@ -112,6 +113,19 @@ class Payment(Base): back_populates="payments_where_used") +class UserBilling(Base): + __tablename__ = "user_billing" + + user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True) + # Saved payment method for off-session recurring charges (YooKassa) + yookassa_payment_method_id = Column(String, nullable=True, unique=True) + card_last4 = Column(String, nullable=True) + card_network = Column(String, nullable=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") + class PromoCode(Base): __tablename__ = "promo_codes" diff --git a/docker-compose.yml b/docker-compose.yml index 4282534..4fbdbaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: volumes: - ./locales:/app/locales restart: unless-stopped + depends_on: + remnawave-tg-shop-db: + condition: service_healthy remnawave-tg-shop-db: image: postgres:17 @@ -23,6 +26,11 @@ services: networks: - remnawave-network restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 networks: remnawave-network: diff --git a/locales/en.json b/locales/en.json index 9e511fe..cec81e0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -371,6 +371,12 @@ "admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", "my_subscription_details": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", + "autorenew_enable_button": "Enable auto-renew", + "autorenew_disable_button": "Disable auto-renew", + "subscription_autorenew_updated": "Auto-renew settings updated.", + "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.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", "error_payment_gateway": "Payment service error. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b58b394..fe28c90 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -370,6 +370,12 @@ "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "my_subscription_details": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", + "autorenew_enable_button": "Включить автопродление", + "autorenew_disable_button": "Выключить автопродление", + "subscription_autorenew_updated": "Настройки автопродления обновлены.", + "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", + "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", + "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", "error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.", From 6a3b4a6947e067a1b9c0e9350099076527d94ceb Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 10:12:15 +0300 Subject: [PATCH 02/41] Safely extract and serialize payment method details in YooKassa webhook handling - Enhanced the yookassa_webhook_route to safely extract payment method details, including card information, from the payment notification. - Implemented error handling to log exceptions during serialization, improving reliability and debugging capabilities. - Updated the payment processing dictionary to use the newly structured payment method data. --- bot/handlers/user/payment.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 1d6c00a..ddee8f0 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -328,6 +328,33 @@ async def yookassa_webhook_route(request: web.Request): ) return web.Response(status=200, text="ok_error_no_metadata") + # Safely extract payment_method details (SDK objects may not have to_dict) + pm_obj = getattr(payment_data_from_notification, 'payment_method', None) + pm_dict = None + if pm_obj is not None: + try: + card_obj = getattr(pm_obj, 'card', None) + pm_dict = { + "id": getattr(pm_obj, 'id', None), + "type": getattr(pm_obj, 'type', None), + "saved": bool(getattr(pm_obj, 'saved', False)), + "title": getattr(pm_obj, 'title', None), + "card": ( + { + "first6": getattr(card_obj, 'first6', None), + "last4": getattr(card_obj, 'last4', None), + "expiry_month": getattr(card_obj, 'expiry_month', None), + "expiry_year": getattr(card_obj, 'expiry_year', None), + "card_type": getattr(card_obj, 'card_type', None), + } + if card_obj is not None + else None + ), + } + except Exception: + logging.exception("Failed to serialize YooKassa payment_method from webhook") + pm_dict = None + payment_dict_for_processing = { "id": str(payment_data_from_notification.id), @@ -344,8 +371,7 @@ async def yookassa_webhook_route(request: web.Request): "description": str(payment_data_from_notification.description) if payment_data_from_notification.description else None, - "payment_method": payment_data_from_notification.payment_method.to_dict() - if getattr(payment_data_from_notification, 'payment_method', None) else None, + "payment_method": pm_dict, } async with payment_processing_lock: From 09f0de78c6b09216dc47f9ed2ad3bc7fd695c58b Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 10:22:02 +0300 Subject: [PATCH 03/41] Refactor subscription renewal process to utilize panel webhook - Removed the recurring billing task from the bot, shifting the auto-renew functionality to the panel webhook service, which now triggers renewals 24 hours before expiry. - Updated service dependencies to wire the subscription service with the panel webhook for seamless renewal handling. - Adjusted the Subscription model to enable auto-renew by default, enhancing subscription management. --- bot/app/factories/build_services.py | 9 ++++++++ bot/main_bot.py | 31 +-------------------------- bot/services/panel_webhook_service.py | 20 +++++++++++++++++ bot/services/subscription_service.py | 1 + db/models.py | 2 +- 5 files changed, 32 insertions(+), 31 deletions(-) diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 8e84968..68b770b 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -54,6 +54,15 @@ def build_core_services( settings_obj=settings, ) + # Wire services that depend on each other + try: + # Attach YooKassa to subscription service for auto-renew charges + setattr(subscription_service, "yookassa_service", yookassa_service) + # Allow panel webhook to trigger renewals through subscription service + setattr(panel_webhook_service, "subscription_service", subscription_service) + except Exception: + pass + return { "panel_service": panel_service, "subscription_service": subscription_service, diff --git a/bot/main_bot.py b/bot/main_bot.py index 3f1f11b..e25a0dc 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -293,36 +293,7 @@ async def run_bot(settings_param: Settings): main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) - async def recurring_billing_task(): - # Run periodic check to bill 1 day before expiry - async_session_factory = dp.get("async_session_factory") - subscription_service = dp.get("subscription_service") - if not async_session_factory or not subscription_service: - logging.warning("Recurring billing task: dependencies missing; task not started") - return - while True: - try: - async with async_session_factory() as session: - # Find subscriptions ending in 1 day - subs = await subscription_service.get_subscriptions_ending_soon(session, 1) - # We need actual Subscription objects; reuse DAL directly - from db.dal import subscription_dal - subs_models = await subscription_dal.get_subscriptions_near_expiration(session, 1) - handled = 0 - for sub in subs_models: - try: - ok = await subscription_service.charge_subscription_renewal(session, sub) - handled += 1 if ok else 0 - except Exception: - logging.exception("Auto-renew attempt failed") - if handled: - await session.commit() - except Exception: - logging.exception("Recurring billing iteration failed") - # Sleep 1 hour between scans - await asyncio.sleep(3600) - - main_tasks.append(asyncio.create_task(recurring_billing_task(), name="RecurringBillingTask")) + # Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here. logging.info("Starting bot in Webhook mode with AIOHTTP server...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index cee7ada..fcef89a 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -185,6 +185,26 @@ class PanelWebhookService: if event_name in EVENT_MAP: days_left, msg_key = EVENT_MAP[event_name] + if days_left == 1: + # Trigger auto-renew via SubscriptionService (wired in at factory) + try: + subscription_service = getattr(self, "subscription_service", None) + if subscription_service: + async with self.async_session_factory() as session: + from db.dal import subscription_dal + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) + if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + try: + ok = await subscription_service.charge_subscription_renewal(session, sub) + if ok: + await session.commit() + else: + await session.rollback() + except Exception: + await session.rollback() + logging.exception("Auto-renew attempt (24h) failed") + except Exception: + logging.exception("Auto-renew trigger (24h) failed pre-check") if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: await self._send_message( user_id, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 3fb6c46..206eea7 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -509,6 +509,7 @@ class SubscriptionService: "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, + "auto_renew_enabled": True, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( diff --git a/db/models.py b/db/models.py index c53e23a..cc36312 100644 --- a/db/models.py +++ b/db/models.py @@ -72,7 +72,7 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) - auto_renew_enabled = Column(Boolean, default=False, index=True) + auto_renew_enabled = Column(Boolean, default=True, index=True) user = relationship("User", back_populates="subscriptions") From c607b6ef85f4796e5e5d3b19466f521ae3b49eb3 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 10:32:39 +0300 Subject: [PATCH 04/41] Enhance payment processing for auto-renew subscriptions - Updated the payment processing logic to handle cases where payment_db_id may be absent for auto-renewal scenarios, ensuring idempotent creation of payment records using provider payment IDs. - Implemented error handling for ensuring payment records and backfilling yookassa_payment_id, improving reliability in processing auto-renewal webhooks. - Enhanced logging for better traceability of payment record creation and updates. --- bot/handlers/user/payment.py | 48 ++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index ddee8f0..54b097c 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -40,8 +40,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, subscription_months_str = metadata.get("subscription_months") promo_code_id_str = metadata.get("promo_code_id") payment_db_id_str = metadata.get("payment_db_id") + auto_renew_subscription_id_str = metadata.get( + "auto_renew_for_subscription_id") - if not user_id_str or not subscription_months_str or not payment_db_id_str: + # For auto-renew payments, payment_db_id may be absent. In that case, + # we will create/ensure a payment record idempotently using provider payment id. + if (not user_id_str or not subscription_months_str + or (not payment_db_id_str and not auto_renew_subscription_id_str)): logging.error( f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" ) @@ -51,7 +56,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, try: user_id = int(user_id_str) subscription_months = int(subscription_months_str) - payment_db_id = int(payment_db_id_str) + payment_db_id = int( + payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None promo_code_id = int( promo_code_id_str ) if promo_code_id_str and promo_code_id_str.isdigit() else None @@ -59,6 +65,44 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, amount_data = payment_info_from_webhook.get("amount", {}) payment_value = float(amount_data.get("value", 0.0)) + # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists + if payment_db_id is None and auto_renew_subscription_id_str: + try: + # Create/ensure provider payment by YooKassa payment id for idempotency + yk_payment_id_from_hook = payment_info_from_webhook.get("id") + from db.dal import payment_dal as _payment_dal + ensured_payment = await _payment_dal.ensure_payment_with_provider_id( + session, + user_id=user_id, + amount=payment_value, + currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL), + months=subscription_months, + description=payment_info_from_webhook.get( + "description") or f"Auto-renewal for {subscription_months} months", + provider="yookassa", + provider_payment_id=yk_payment_id_from_hook, + ) + payment_db_id = ensured_payment.payment_id + # Also persist yookassa_payment_id field if not set yet + try: + await _payment_dal.update_payment_status_by_db_id( + session, + payment_db_id, + payment_info_from_webhook.get("status", "succeeded"), + yk_payment_id_from_hook, + ) + except Exception: + # Non-fatal; continue processing + logging.exception( + "Failed to backfill yookassa_payment_id for ensured auto-renew payment" + ) + except Exception as e_ensure: + logging.error( + f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", + exc_info=True, + ) + return + db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: logging.error( From 9b4dab84addc390165d743bb8ca93cf5e89c1afc Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 10:44:20 +0300 Subject: [PATCH 05/41] Enhance auto-renewal messaging and payment processing logic - Introduced a new flag for auto-renew subscriptions to streamline messaging and avoid redundant configuration links. - Updated localization files to include a new message for auto-renewal notifications in both English and Russian. - Improved error handling and logging in the payment processing flow to ensure clarity and reliability during subscription renewals. --- bot/handlers/user/payment.py | 90 +++++++++++++++------------ bot/services/panel_webhook_service.py | 2 + locales/en.json | 1 + locales/ru.json | 1 + 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 54b097c..b4a4ba1 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -58,6 +58,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, subscription_months = int(subscription_months_str) payment_db_id = int( payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None + is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id) promo_code_id = int( promo_code_id_str ) if promo_code_id_str and promo_code_id_str.isdigit() else None @@ -199,53 +200,62 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) - config_link = activation_details.get("subscription_url") or _( - "config_link_not_available" - ) - - if applied_referee_bonus_days_from_referral and final_end_date_for_user: - inviter_name_display = _("friend_placeholder") - if db_user and db_user.referred_by_id: - inviter = await user_dal.get_user_by_id( - session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" - + # For auto-renew charges, avoid re-sending config link; send concise message + if is_auto_renew and final_end_date_for_user: details_message = _( - "payment_successful_with_referral_bonus_full", - months=subscription_months, - base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), - bonus_days=applied_referee_bonus_days_from_referral, - final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), - inviter_name=inviter_name_display, - config_link=config_link, - ) - elif applied_promo_bonus_days > 0 and final_end_date_for_user: - details_message = _( - "payment_successful_with_promo_full", - months=subscription_months, - bonus_days=applied_promo_bonus_days, - end_date=final_end_date_for_user.strftime('%Y-%m-%d'), - config_link=config_link, - ) - elif final_end_date_for_user: - details_message = _( - "payment_successful_full", + "yookassa_auto_renewal", months=subscription_months, end_date=final_end_date_for_user.strftime('%Y-%m-%d'), - config_link=config_link, ) + details_markup = None else: - logging.error( - f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." + config_link = activation_details.get("subscription_url") or _( + "config_link_not_available" ) - details_message = _("payment_successful_error_details") - details_markup = get_connect_and_main_keyboard( - user_lang, i18n, settings, config_link - ) + if applied_referee_bonus_days_from_referral and final_end_date_for_user: + inviter_name_display = _("friend_placeholder") + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id( + session, db_user.referred_by_id) + if inviter and inviter.first_name: + inviter_name_display = inviter.first_name + elif inviter and inviter.username: + inviter_name_display = f"@{inviter.username}" + + details_message = _( + "payment_successful_with_referral_bonus_full", + months=subscription_months, + base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), + bonus_days=applied_referee_bonus_days_from_referral, + final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), + inviter_name=inviter_name_display, + config_link=config_link, + ) + elif applied_promo_bonus_days > 0 and final_end_date_for_user: + details_message = _( + "payment_successful_with_promo_full", + months=subscription_months, + bonus_days=applied_promo_bonus_days, + end_date=final_end_date_for_user.strftime('%Y-%m-%d'), + config_link=config_link, + ) + elif final_end_date_for_user: + details_message = _( + "payment_successful_full", + months=subscription_months, + end_date=final_end_date_for_user.strftime('%Y-%m-%d'), + config_link=config_link, + ) + else: + logging.error( + f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." + ) + details_message = _("payment_successful_error_details") + + details_markup = get_connect_and_main_keyboard( + user_lang, i18n, settings, config_link + ) try: await bot.send_message( user_id, diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index fcef89a..c2ec807 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -196,8 +196,10 @@ class PanelWebhookService: if sub and sub.auto_renew_enabled and sub.provider != 'tribute': try: ok = await subscription_service.charge_subscription_renewal(session, sub) + # If initiation succeeded, suppress the 24h reminder by returning early if ok: await session.commit() + return else: await session.rollback() except Exception: diff --git a/locales/en.json b/locales/en.json index cec81e0..53a63f4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -211,6 +211,7 @@ "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", + "yookassa_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}", "admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", "admin_user_subscription_info": "Subscription Information:", "admin_user_reset_trial_button": "🔄 Reset Trial", diff --git a/locales/ru.json b/locales/ru.json index fe28c90..651b26d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -140,6 +140,7 @@ "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", + "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_promo_set_validity_days": "⏰ Установить срок (дни)", "admin_back_to_panel": "⬅️ В панель", "admin_promo_unlimited": "♾️ Неограниченно", From 38f3b1ad743a4259ab2540a877581abfa8ce46b7 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 16:28:02 +0300 Subject: [PATCH 06/41] Enhance localization and fallback logic in i18n middleware - Improved the effective language determination process by adding robust fallbacks to ensure a valid language is always used. - Updated the gettext method to include explicit fallback to English if the requested language data is unavailable. - Corrected a typo in the Russian localization for auto-renewal messaging to ensure clarity in user notifications. --- bot/middlewares/i18n.py | 19 ++++++++++++++++++- locales/ru.json | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py index 80bb2b8..7140bb5 100644 --- a/bot/middlewares/i18n.py +++ b/bot/middlewares/i18n.py @@ -45,10 +45,27 @@ class JsonI18n: exc_info=True) def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str: - effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang + # Determine effective language with robust fallback + if lang_code and lang_code in self.locales_data: + effective_lang_code = lang_code + elif self.default_lang in self.locales_data: + effective_lang_code = self.default_lang + elif 'en' in self.locales_data: + effective_lang_code = 'en' + else: + effective_lang_code = lang_code or self.default_lang lang_data = self.locales_data.get(effective_lang_code) if lang_data is None: + # Try explicit fallback to English if available + fallback_data = self.locales_data.get('en') + if fallback_data is not None: + text = fallback_data.get(key) + if text is not None: + try: + return text.format(**kwargs) if kwargs else text + except Exception: + return text logging.warning( f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." ) diff --git a/locales/ru.json b/locales/ru.json index 651b26d..53a60b8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -140,7 +140,7 @@ "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", - "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", + "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_promo_set_validity_days": "⏰ Установить срок (дни)", "admin_back_to_panel": "⬅️ В панель", "admin_promo_unlimited": "♾️ Неограниченно", From 0f69823192a7adb00fe6c5ac6791e218f2e368b9 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 16:32:18 +0300 Subject: [PATCH 07/41] Refactor subscription fetching logic in toggle_autorenew_handler - Updated the subscription retrieval process to directly fetch the Subscription model by ID, improving clarity and reducing potential import cycle issues. - Enhanced code readability by adding a comment to clarify the purpose of the subscription fetch operation. --- bot/handlers/user/subscription.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 0202757..a4f5703 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -20,6 +20,7 @@ from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService from bot.middlewares.i18n import JsonI18n from db.dal import subscription_dal +from db.models import Subscription router = Router(name="user_subscription_router") @@ -564,8 +565,7 @@ async def toggle_autorenew_handler(callback: types.CallbackQuery, settings: Sett pass return - sub = await session.get(type(subscription_service).__annotations__.get('sub', Subscription), sub_id) # fallback avoids import cycle - # Better: direct DAL fetch + # Fetch subscription by ID directly 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) From 5b98e1a40c5ade411e7c65f6c4fadb376393c3a5 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 18:07:18 +0300 Subject: [PATCH 08/41] Implement payment methods management and binding functionality - Added new handlers for managing payment methods, including viewing, binding, and deleting payment methods. - Introduced new inline keyboard options for payment method management in user interactions. - Enhanced the YooKassa service to support card binding with minimal payment requirements. - Updated localization files to include new messages related to payment methods and their management. --- bot/handlers/user/subscription.py | 128 ++++++++++++++++++++++++- bot/keyboards/inline/user_keyboards.py | 48 ++++++++++ bot/services/yookassa_service.py | 8 +- db/dal/user_billing_dal.py | 13 +++ locales/en.json | 14 +++ locales/ru.json | 14 +++ 6 files changed, 222 insertions(+), 3 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index a4f5703..fd27b2e 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -10,7 +10,9 @@ from config.settings import Settings from db.dal import payment_dal 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_url_keyboard, get_back_to_main_menu_markup, + get_payment_methods_manage_keyboard, get_payment_method_delete_confirm_keyboard, + get_payment_method_details_keyboard, get_bind_url_keyboard) from bot.services.yookassa_service import YooKassaService from db.dal import user_billing_dal from bot.services.stars_service import StarsService @@ -18,6 +20,7 @@ from bot.services.crypto_pay_service import CryptoPayService from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService 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 @@ -523,13 +526,15 @@ async def my_subscription_command_handler( else get_text("traffic_na") ) ) - # Build markup with auto-renew toggle if available + # Build markup with auto-renew toggle and payment methods if available base_markup = get_back_to_main_menu_markup(current_lang, i18n) kb = base_markup.inline_keyboard try: if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") kb = [[InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}")]] + kb + # Add payment methods manage entry point + kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb except Exception: pass markup = InlineKeyboardMarkup(inline_keyboard=kb) @@ -585,6 +590,125 @@ async def toggle_autorenew_handler(callback: types.CallbackQuery, settings: Sett await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) +@router.callback_query(F.data == "pm:manage") +async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + has_card = bool(billing and billing.yookassa_payment_method_id) + text = _("payment_methods_title") + if not has_card: + text += "\n\n" + _("payment_method_none") + await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:bind") +async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + # Create a minimal binding payment (1 RUB) with save_payment_method + metadata = { + "user_id": str(callback.from_user.id), + "bind_only": "1", + } + resp = await yookassa_service.create_payment( + amount=1.00, + currency="RUB", + description="Bind card", + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=True, + capture=False, + bind_only=True, + ) + if not resp or not resp.get("confirmation_url"): + await callback.answer(_("error_payment_gateway"), show_alert=True) + return + await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:delete_confirm") +async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:delete") +async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) + await session.commit() + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") + await callback.message.edit_text(msg, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=False)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:view") +async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + if not billing or not billing.yookassa_payment_method_id: + await callback.answer(_("payment_method_none"), show_alert=True) + return + added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—" + # Placeholder for last tx; real data requires querying payments + last_tx = "—" + title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4 or "????") + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:history") +async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + # Simple history from payments table filtered by user + from db.dal import payment_dal + payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10, offset=0) + user_payments = [p for p in payments if p.user_id == callback.from_user.id] + if not user_payments: + await callback.message.edit_text(_("payment_method_no_history"), reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)) + return + lines = [ + f"{p.created_at.strftime('%Y-%m-%d')} — {p.amount} {p.currency} — {p.provider} — {p.status}" + for p in user_payments + ] + text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) + await callback.message.edit_text(text, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)) + + @router.pre_checkout_query() 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 6a0898f..4aa5448 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -229,3 +229,51 @@ def get_connect_and_main_keyboard( ) return builder.as_markup() + + +def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + if has_card: + builder.row( + InlineKeyboardButton(text=_(key="payment_method_view_button"), callback_data="pm:view"), + InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data="pm:delete_confirm"), + ) + builder.row( + InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind") + ) + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main") + ) + return builder.as_markup() + + +def get_payment_method_delete_confirm_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="yes_button"), callback_data="pm:delete"), + InlineKeyboardButton(text=_(key="cancel_button"), callback_data="pm:manage"), + ) + return builder.as_markup() + + +def get_payment_method_details_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data="pm:history") + ) + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:manage") + ) + return builder.as_markup() + + +def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="payment_method_bind_button"), url=bind_url) + builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage") + builder.adjust(1) + return builder.as_markup() diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 1d98734..3fe4b20 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -6,6 +6,7 @@ from typing import Optional, Dict, Any, List from yookassa import Configuration, Payment as YooKassaPayment from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder from yookassa.domain.common.confirmation_type import ConfirmationType +from yookassa.domain.models.payment_data import PaymentMethodType from config.settings import Settings @@ -64,7 +65,8 @@ class YooKassaService: receipt_phone: Optional[str] = None, save_payment_method: bool = False, payment_method_id: Optional[str] = None, - capture: bool = True) -> Optional[Dict[str, Any]]: + capture: bool = True, + bind_only: bool = False) -> Optional[Dict[str, Any]]: if not self.configured: logging.error("YooKassa is not configured. Cannot create payment.") return None @@ -105,6 +107,10 @@ class YooKassaService: "value": str(round(amount, 2)), "currency": currency.upper() }) + # For binding cards only, do not capture and set minimal amount + if bind_only: + capture = False + amount = max(amount, 1.00) builder.set_capture(capture) builder.set_confirmation({ "type": ConfirmationType.REDIRECT, diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 597fd75..78d80a5 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -39,3 +39,16 @@ async def upsert_yk_payment_method( await session.flush() await session.refresh(record) return record + + +async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool: + existing = await get_user_billing(session, user_id) + if not existing: + return False + existing.yookassa_payment_method_id = None + existing.card_last4 = None + existing.card_network = None + existing.updated_at = func.now() + await session.flush() + await session.refresh(existing) + return True diff --git a/locales/en.json b/locales/en.json index 53a63f4..31619e9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -375,6 +375,20 @@ "autorenew_enable_button": "Enable auto-renew", "autorenew_disable_button": "Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", + "payment_methods_manage_button": "💳 Payment Methods", + "payment_methods_title": "💳 Payment Methods", + "payment_method_bind_button": "➕ Add card", + "payment_method_delete_button": "🗑 Remove", + "payment_method_view_button": "ℹ️ Details", + "payment_method_none": "You don't have a saved card yet.", + "payment_method_bound_success": "✅ Card successfully added.", + "payment_method_deleted_success": "✅ Payment method removed.", + "payment_method_delete_confirm": "Remove saved payment method?", + "payment_method_card_title": "💳 Card {network} ••••{last4}", + "payment_method_added_at": "Added: {date}", + "payment_method_last_tx": "Last transaction: {date}", + "payment_method_tx_history_title": "📜 Transactions history", + "payment_method_no_history": "No transactions history.", "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", "subscription_tribute_notice_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 53a60b8..d81635f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -374,6 +374,20 @@ "autorenew_enable_button": "Включить автопродление", "autorenew_disable_button": "Выключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", + "payment_methods_manage_button": "💳 Способы оплаты", + "payment_methods_title": "💳 Способы оплаты", + "payment_method_bind_button": "➕ Привязать карту", + "payment_method_delete_button": "🗑 Удалить", + "payment_method_view_button": "ℹ️ Детали", + "payment_method_none": "У вас пока нет сохранённой карты.", + "payment_method_bound_success": "✅ Карта успешно привязана.", + "payment_method_deleted_success": "✅ Способ оплаты удалён.", + "payment_method_delete_confirm": "Удалить сохранённый способ оплаты?", + "payment_method_card_title": "💳 Карта {network} ••••{last4}", + "payment_method_added_at": "Добавлена: {date}", + "payment_method_last_tx": "Последняя операция: {date}", + "payment_method_tx_history_title": "📜 История операций", + "payment_method_no_history": "История операций отсутствует.", "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", From e0e2cde9a7b6cb9a54bed3f1b290fbcc5b1e9fc5 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 19:56:31 +0300 Subject: [PATCH 09/41] Remove unused import for PaymentMethodType in yookassa_service.py to clean up code and improve readability. --- bot/services/yookassa_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 3fe4b20..dd68ff2 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -6,7 +6,6 @@ from typing import Optional, Dict, Any, List from yookassa import Configuration, Payment as YooKassaPayment from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder from yookassa.domain.common.confirmation_type import ConfirmationType -from yookassa.domain.models.payment_data import PaymentMethodType from config.settings import Settings From 257597ccb7e93f5ae48934e21734a9b616ea5cf0 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 16:30:45 +0300 Subject: [PATCH 10/41] Enhance payment processing and user detail updates in YooKassa integration - Added handling for 'waiting_for_capture' event in the YooKassa webhook to manage bind-only payment flows, including saving payment methods and canceling authorizations. - Introduced a new method in the YooKassaService to cancel payments, improving error handling and logging for payment cancellations. - Updated user detail synchronization to conditionally update descriptions only when they differ from the current panel state, enhancing efficiency. --- bot/handlers/admin/sync_admin.py | 5 ++++- bot/handlers/user/payment.py | 29 +++++++++++++++++++++++++++++ bot/services/yookassa_service.py | 13 +++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 1428fe2..39426cb 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -144,7 +144,10 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession, existing_user.first_name or "", existing_user.last_name or "", ]) - if description_text.strip(): + # Update description only when it differs from the current one on panel + current_panel_description = (panel_user_dict.get("description") or "").strip() + desired_description = description_text.strip() + if desired_description and desired_description != current_panel_description: await panel_service.update_user_details_on_panel( panel_uuid, {"description": description_text} ) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index b4a4ba1..83534b2 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -27,6 +27,7 @@ payment_processing_lock = asyncio.Lock() YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded' YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled' +YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture' async def process_successful_payment(session: AsyncSession, bot: Bot, @@ -451,6 +452,34 @@ async def yookassa_webhook_route(request: web.Request): session, bot, payment_dict_for_processing, i18n_instance, settings) await session.commit() + elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: + # Bind-only flow: save method and cancel auth if metadata has bind_only + metadata = payment_dict_for_processing.get("metadata", {}) or {} + if metadata.get("bind_only") == "1": + try: + user_id_str = metadata.get("user_id") + if user_id_str and user_id_str.isdigit(): + user_id = int(user_id_str) + payment_method = payment_dict_for_processing.get("payment_method") + if isinstance(payment_method, dict) and payment_method.get("id"): + card = payment_method.get("card") or {} + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=payment_method.get("id"), + card_last4=card.get("last4"), + card_network=card.get("card_type"), + ) + await session.commit() + # Attempt to cancel the authorization to avoid charge hold + try: + yk: YooKassaService = request.app.get('yookassa_service') + if yk: + await yk.cancel_payment(payment_dict_for_processing.get("id")) + except Exception: + logging.exception("Failed to cancel bind-only payment auth") + except Exception: + logging.exception("Failed to handle bind-only waiting_for_capture webhook") except Exception as e_webhook_db_processing: await session.rollback() logging.error( diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index dd68ff2..50d61e8 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -206,6 +206,19 @@ class YooKassaService: logging.error( "YooKassa is not configured. Cannot get payment info.") return None + + async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: + if not self.configured: + logging.error("YooKassa is not configured. Cannot cancel payment.") + return False + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa)) + logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") + return True + except Exception as e: + logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}") + return False try: logging.info( f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}" From 6b5828ea51e981ad643ef1705168439aca54e6c6 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:11:18 +0300 Subject: [PATCH 11/41] 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.", From 50cea15c9256618b02eab420bd2dde4392929b75 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:24:24 +0300 Subject: [PATCH 12/41] Enhance localization for user-facing messages across payment and subscription services - Updated various services to utilize the user's database language for all user-facing messages, improving the localization experience. - Refactored message retrieval logic in payment processing, subscription management, and notification handling to ensure consistency in language usage. - Added comments to clarify the purpose of language settings in relevant sections of the code. --- bot/handlers/user/payment.py | 2 ++ bot/handlers/user/subscription.py | 10 +++++----- bot/services/crypto_pay_service.py | 1 + bot/services/stars_service.py | 5 +++-- bot/services/tribute_service.py | 1 + 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 69b65f2..2f7f473 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -198,6 +198,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, applied_referee_bonus_days_from_referral = referral_bonus_info.get( "referee_bonus_applied_days") + # Use user's DB language for all user-facing messages user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) @@ -488,6 +489,7 @@ async def yookassa_webhook_route(request: web.Request): await session.rollback() # Notify user about successful binding with Back button try: + # Use user's DB language for bind success notification i18n_lang = settings.DEFAULT_LANGUAGE from db.dal import user_dal db_user = await user_dal.get_user_by_id(session, user_id) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index f168f02..8d74983 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -2,7 +2,7 @@ import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice -from typing import Optional, Dict, Any, Union +from typing import Optional, Dict, Any, Union, List from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -774,14 +774,14 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin 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 + get_text = 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 "????") + title = get_text("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 @@ -791,9 +791,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings except Exception: page = 0 - text = _("payment_methods_title") + text = get_text("payment_methods_title") if not cards: - text += "\n\n" + _("payment_method_none") + 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() diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 718f0c2..d6edd24 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -155,6 +155,7 @@ class CryptoPayService: return db_user = await user_dal.get_user_by_id(session, user_id) + # Use DB language for user-facing messages lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda k, **kw: i18n.gettext(lang, k, **kw) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index eaf36cc..b48b4b5 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -109,8 +109,9 @@ class StarsService: if not final_end: final_end = activation_details["end_date"] - current_lang = i18n_data.get("current_language", - self.settings.DEFAULT_LANGUAGE) + # Always use user's language from DB for user-facing messages + db_user = await user_dal.get_user_by_id(session, message.from_user.id) + current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE i18n: JsonI18n = i18n_data.get("i18n_instance") _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index a7d4403..06d1d25 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -209,6 +209,7 @@ class TributeService: ) try: + # Use user's DB language in success messages prepared above await bot.send_message( int(user_id), success_msg, From a510ab10212bbbb543b9946626e27da9140e5e49 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:32:27 +0300 Subject: [PATCH 13/41] Refactor payment methods management to utilize a paginated list view - Updated the payment methods management handler to directly build and display a paginated list of user payment methods, enhancing user experience. - Removed legacy support for single card checks and deprecated keyboard functions in favor of the new list view. - Improved localization handling for payment method titles and added fallback options for missing data. --- bot/handlers/user/subscription.py | 22 ++++++++++++++-------- bot/keyboards/inline/user_keyboards.py | 8 ++------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 8d74983..c70ec19 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -598,14 +598,20 @@ 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)) + # Build and show the paginated list directly (page 0) + 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] = [] + for m in methods: + title = get_text("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}")) + + 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: diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 3bc958c..db2ca99 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -235,10 +235,6 @@ def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool """Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - # 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") ) @@ -305,7 +301,7 @@ def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> 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") + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0") ) return builder.as_markup() @@ -322,5 +318,5 @@ def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyb 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")) + builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")) return builder.as_markup() From 793412c47279dc646762e6995e776fa1672aca76 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:44:31 +0300 Subject: [PATCH 14/41] Enhance payment method history handling and navigation - Added a new keyboard function to facilitate navigation back to specific payment method details. - Updated the payment method history handler to utilize the new back navigation option, improving user experience when no payment history is available. - Enhanced error handling for extracting payment method IDs from callback data to ensure smoother navigation. --- bot/handlers/user/subscription.py | 18 +++++++++++++++--- bot/keyboards/inline/user_keyboards.py | 8 ++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index c70ec19..0e33037 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -14,7 +14,7 @@ from bot.keyboards.inline.user_keyboards import ( 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_methods_list_keyboard, get_back_to_payment_methods_keyboard) + get_payment_methods_list_keyboard, get_back_to_payment_methods_keyboard, get_back_to_payment_method_details_keyboard) from bot.services.yookassa_service import YooKassaService from db.dal import user_billing_dal from bot.services.stars_service import StarsService @@ -763,7 +763,14 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10, offset=0) user_payments = [p for p in payments if p.user_id == callback.from_user.id] if not user_payments: - await callback.message.edit_text(_("payment_method_no_history"), reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)) + # Try to get pm_id from context to go one step back + pm_id = "" + try: + _, _, pm_id = callback.data.split(":", 2) + except Exception: + pm_id = "" + back_markup = get_back_to_payment_method_details_keyboard(pm_id, current_lang, i18n) if 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 # Show subscription purchase titles instead of raw provider/status def _format_item(p): @@ -773,7 +780,12 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin 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)) + try: + _, _, pm_id = callback.data.split(":", 2) + except Exception: + pm_id = "" + back_markup = get_back_to_payment_method_details_keyboard(pm_id, current_lang, i18n) if pm_id 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:")) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index db2ca99..4ed92b4 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -320,3 +320,11 @@ def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyb builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")) return builder.as_markup() + + +def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + # Back one step: return to specific payment method details + builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}")) + return builder.as_markup() From 850f135bf8c14895e4d7f066ef219dcf66e98a96 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 17:52:21 +0300 Subject: [PATCH 15/41] Implement multi-card deletion for payment methods with enhanced error handling - Added functionality to delete specific payment methods based on their ID, supporting multi-card management. - Improved error handling during deletion processes, including rollback mechanisms for database transactions. - Updated user notifications to reflect the success or failure of deletion attempts, ensuring a consistent user experience. - Refactored the response messages to include updated lists of remaining payment methods after deletion. --- bot/handlers/user/subscription.py | 53 ++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 0e33037..5889620 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -667,11 +667,56 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting 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() + # Try to parse specific method id for multi-card deletion + pm_id_raw = callback.data.split(":", 1)[-1] if ":" in callback.data else "" + deleted = False + # Attempt multi-card deletion first + try: + if pm_id_raw and pm_id_raw.isdigit(): + from db.dal.user_billing_dal import delete_user_payment_method, list_user_payment_methods + deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw)) + await session.commit() + # Build updated list + methods = await list_user_payment_methods(session, callback.from_user.id) + text = _("payment_methods_title") + cards = [] + 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}")) + if not cards: + text += "\n\n" + _("payment_method_none") + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") + # Prepend status message to title + 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() + deleted = False + + # Fallback: legacy single-card storage deletion + try: + deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) + await session.commit() + except Exception: + await session.rollback() + deleted = False + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") - await callback.message.edit_text(msg, reply_markup=get_payment_methods_manage_keyboard(current_lang, i18n, has_card=False)) + # After legacy deletion, route user to list (which will be empty) for consistency + from db.dal.user_billing_dal import list_user_payment_methods + methods = await list_user_payment_methods(session, callback.from_user.id) + cards = [] + 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}")) + text = _("payment_methods_title") + if not cards: + text += "\n\n" + _("payment_method_none") + 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: From cf7f0eae5164f24926e7bfb6e1a84255c012604b Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:00:04 +0300 Subject: [PATCH 16/41] Refactor payment method ID extraction for improved reliability - Updated the logic for extracting payment method IDs from callback data to handle cases with multiple segments, ensuring more robust parsing. - Enhanced the consistency of ID retrieval across different payment method handlers, improving overall code reliability and user experience. --- bot/handlers/user/subscription.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 5889620..0d2ca3a 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -654,7 +654,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: 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 - pm_id = callback.data.split(":", 1)[-1] if ":" in callback.data else "" + 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() @@ -668,7 +669,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key # Try to parse specific method id for multi-card deletion - pm_id_raw = callback.data.split(":", 1)[-1] if ":" in callback.data else "" + parts = callback.data.split(":", 2) + pm_id_raw = parts[2] if len(parts) >= 3 else "" deleted = False # Attempt multi-card deletion first try: @@ -737,7 +739,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, 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) + parts = callback.data.split(":", 2) + pm_id = parts[2] if len(parts) >= 3 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 "????") From a5aecb0f866e0bafcb86f7cac9b566c6ba14ec7f Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:14:32 +0300 Subject: [PATCH 17/41] Enhance payment method display logic and localization updates - Improved the logic for displaying payment method details, including card type and last four digits, to provide a clearer user experience. - Updated localization strings to reflect changes in payment method terminology, ensuring consistency across user-facing messages. - Refactored payment method handlers to utilize the new display logic, enhancing the overall management of payment methods. --- bot/handlers/user/payment.py | 35 ++++++++++++++++++++----- bot/handlers/user/subscription.py | 43 +++++++++++++++++++++++++------ locales/en.json | 9 ++++--- locales/ru.json | 9 ++++--- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 2f7f473..361df09 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -140,13 +140,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, payment_method = payment_info_from_webhook.get("payment_method") if isinstance(payment_method, dict) and payment_method.get("saved", False): pm_id = payment_method.get("id") + pm_type = payment_method.get("type") + title = payment_method.get("title") card = payment_method.get("card") or {} + display_network = None + display_last4 = None + # Build generic display for various instrument types + if (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") + else: + # Wallets, SBP, etc. — use provided title/type; no last4 + 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=card.get("last4"), - card_network=card.get("card_type"), + card_last4=display_last4, + card_network=display_network, ) except Exception: logging.exception("Failed to persist YooKassa payment method from webhook") @@ -463,13 +476,23 @@ async def yookassa_webhook_route(request: web.Request): user_id = int(user_id_str) payment_method = payment_dict_for_processing.get("payment_method") if isinstance(payment_method, dict) and payment_method.get("id"): + pm_type = payment_method.get("type") + title = payment_method.get("title") card = payment_method.get("card") or {} + display_network = None + display_last4 = None + if (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") + 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=payment_method.get("id"), - card_last4=card.get("last4"), - card_network=card.get("card_type"), + card_last4=display_last4, + card_network=display_network, ) await session.commit() # Save multi-card entry and mark default if first @@ -480,8 +503,8 @@ async def yookassa_webhook_route(request: web.Request): 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"), + card_last4=display_last4, + card_network=display_network, set_default=True, ) await session.commit() diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 0d2ca3a..fe005ae 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -313,12 +313,21 @@ async def pay_yk_callback_handler( 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 {} + 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') + 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=pm.get('last4'), - card_network=pm.get('card', {}).get('card_type') if isinstance(pm.get('card'), dict) else None, + card_last4=display_last4, + card_network=display_network, ) await session.commit() except Exception: @@ -604,7 +613,10 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin methods = await list_user_payment_methods(session, callback.from_user.id) cards: List[tuple] = [] for m in methods: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4 or "????") + if m.card_last4: + title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + else: + title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) text = get_text("payment_methods_title") @@ -683,7 +695,10 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting text = _("payment_methods_title") cards = [] for m in methods: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4 or "????") + if m.card_last4: + title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + else: + title = _("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) if not cards: text += "\n\n" + _("payment_method_none") @@ -713,7 +728,10 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting methods = await list_user_payment_methods(session, callback.from_user.id) cards = [] for m in methods: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4 or "????") + if m.card_last4: + title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + else: + title = _("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) text = _("payment_methods_title") if not cards: @@ -743,7 +761,10 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, pm_id = parts[2] if len(parts) >= 3 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 "????") + if sel.card_last4: + title = _("payment_method_card_title", network=sel.card_network or "Card", last4=sel.card_last4) + else: + title = _("payment_method_generic_title", network=sel.card_network or "Payment method") added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—" # Last tx last_tx = "—" @@ -791,7 +812,10 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, 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 "????") + if billing.card_last4: + title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4) + else: + title = _("payment_method_generic_title", network=billing.card_network or "Payment method") 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: @@ -847,7 +871,10 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings cards: List[tuple] = [] methods = await list_user_payment_methods(session, callback.from_user.id) for m in methods: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4 or "????") + if m.card_last4: + title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + else: + title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) # Parse page diff --git a/locales/en.json b/locales/en.json index a171d61..b66d666 100644 --- a/locales/en.json +++ b/locales/en.json @@ -377,14 +377,15 @@ "subscription_autorenew_updated": "Auto-renew settings updated.", "payment_methods_manage_button": "💳 Payment Methods", "payment_methods_title": "💳 Payment Methods", - "payment_method_bind_button": "➕ Add card", + "payment_method_bind_button": "➕ Add method", "payment_method_delete_button": "🗑 Remove", "payment_method_view_button": "ℹ️ Details", - "payment_method_none": "You don't have a saved card yet.", - "payment_method_bound_success": "✅ Card successfully added.", + "payment_method_none": "You don't have a saved payment method yet.", + "payment_method_bound_success": "✅ Payment method added.", "payment_method_deleted_success": "✅ Payment method removed.", "payment_method_delete_confirm": "Remove saved payment method?", - "payment_method_card_title": "💳 Card {network} ••••{last4}", + "payment_method_card_title": "💳 {network} ••••{last4}", + "payment_method_generic_title": "💳 {network}", "payment_method_added_at": "Added: {date}", "payment_method_last_tx": "Last transaction: {date}", "payment_method_tx_history_title": "📜 Transactions history", diff --git a/locales/ru.json b/locales/ru.json index d6af159..199436d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -376,14 +376,15 @@ "subscription_autorenew_updated": "Настройки автопродления обновлены.", "payment_methods_manage_button": "💳 Способы оплаты", "payment_methods_title": "💳 Способы оплаты", - "payment_method_bind_button": "➕ Привязать карту", + "payment_method_bind_button": "➕ Добавить способ", "payment_method_delete_button": "🗑 Удалить", "payment_method_view_button": "ℹ️ Детали", - "payment_method_none": "У вас пока нет сохранённой карты.", - "payment_method_bound_success": "✅ Карта успешно привязана.", + "payment_method_none": "У вас пока нет сохранённого способа оплаты.", + "payment_method_bound_success": "✅ Способ оплаты добавлен.", "payment_method_deleted_success": "✅ Способ оплаты удалён.", "payment_method_delete_confirm": "Удалить сохранённый способ оплаты?", - "payment_method_card_title": "💳 Карта {network} ••••{last4}", + "payment_method_card_title": "💳 {network} ••••{last4}", + "payment_method_generic_title": "💳 {network}", "payment_method_added_at": "Добавлена: {date}", "payment_method_last_tx": "Последняя операция: {date}", "payment_method_tx_history_title": "📜 История операций", From cb464059d95e3c83e3fdee3772aacfee144a692d Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:18:36 +0300 Subject: [PATCH 18/41] Enhance payment method display for YooMoney integration - Updated the logic for displaying payment method details, specifically for YooMoney, to include account number handling and last four digits extraction. - Improved the consistency of payment method display across different handlers, ensuring a clearer user experience. - Refactored relevant sections in both payment and subscription handlers to accommodate the new display logic. --- bot/handlers/user/payment.py | 21 +++++++++++++++++++++ bot/handlers/user/subscription.py | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 361df09..3b4c083 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -143,12 +143,19 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, pm_type = payment_method.get("type") title = payment_method.get("title") card = payment_method.get("card") or {} + account_number = payment_method.get("account_number") or payment_method.get("account") display_network = None display_last4 = None # Build generic display for various instrument types if (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 = title or "YooMoney" + if isinstance(account_number, str) and len(account_number) >= 4: + display_last4 = account_number[-4:] + else: + display_last4 = None else: # Wallets, SBP, etc. — use provided title/type; no last4 display_network = title or (pm_type.upper() if pm_type else "Payment method") @@ -408,6 +415,13 @@ async def yookassa_webhook_route(request: web.Request): "type": getattr(pm_obj, 'type', None), "saved": bool(getattr(pm_obj, 'saved', False)), "title": getattr(pm_obj, 'title', None), + "account_number": ( + getattr(pm_obj, 'account_number', None) + if hasattr(pm_obj, 'account_number') else ( + getattr(pm_obj, 'account', None) + if hasattr(pm_obj, 'account') else None + ) + ), "card": ( { "first6": getattr(card_obj, 'first6', None), @@ -479,11 +493,18 @@ async def yookassa_webhook_route(request: web.Request): pm_type = payment_method.get("type") title = payment_method.get("title") card = payment_method.get("card") or {} + account_number = payment_method.get("account_number") or payment_method.get("account") display_network = None display_last4 = None if (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 = title or "YooMoney" + if isinstance(account_number, str) and len(account_number) >= 4: + display_last4 = account_number[-4:] + else: + display_last4 = None else: display_network = title or (pm_type.upper() if pm_type else "Payment method") display_last4 = None diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index fe005ae..e0bac3e 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -316,9 +316,13 @@ async def pay_yk_callback_handler( 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 = title or '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 From ba8be7c3f9728dea9aa0d64a4f83ac4294f50fca Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:26:15 +0300 Subject: [PATCH 19/41] Standardize YooMoney wallet display across payment handlers and localization - Updated the display logic for YooMoney wallet in payment and subscription handlers to ensure consistent naming and avoid leaking sensitive account information. - Introduced a new localization string for wallet display, enhancing clarity in user-facing messages. - Refactored relevant sections to improve overall code maintainability and user experience. --- bot/handlers/user/payment.py | 6 ++++-- bot/handlers/user/subscription.py | 33 ++++++++++++++++++++++++------- locales/en.json | 1 + locales/ru.json | 1 + 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 3b4c083..5b5cbe8 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -151,7 +151,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, 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 = title or "YooMoney" + # Normalize wallet display name to avoid leaking full account from title + display_network = "YooMoney" if isinstance(account_number, str) and len(account_number) >= 4: display_last4 = account_number[-4:] else: @@ -500,7 +501,8 @@ async def yookassa_webhook_route(request: web.Request): 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 = title or "YooMoney" + # Normalize wallet display name to avoid leaking full account from title + display_network = "YooMoney" if isinstance(account_number, str) and len(account_number) >= 4: display_last4 = account_number[-4:] else: diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index e0bac3e..aa9b4d3 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -321,7 +321,8 @@ async def pay_yk_callback_handler( 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 = title or 'YooMoney' + # Normalize wallet display name to avoid leaking full account from title + 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') @@ -618,7 +619,10 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin cards: List[tuple] = [] for m in methods: if m.card_last4: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": + title = get_text("payment_method_wallet_title", last4=m.card_last4) + else: + title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) else: title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) @@ -700,7 +704,10 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting cards = [] for m in methods: if m.card_last4: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": + title = _("payment_method_wallet_title", last4=m.card_last4) + else: + title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) else: title = _("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) @@ -733,7 +740,10 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting cards = [] for m in methods: if m.card_last4: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": + title = _("payment_method_wallet_title", last4=m.card_last4) + else: + title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) else: title = _("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) @@ -766,7 +776,10 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, # 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]) if sel.card_last4: - title = _("payment_method_card_title", network=sel.card_network or "Card", last4=sel.card_last4) + if (sel.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (sel.card_network or '') == "YooMoney": + title = _("payment_method_wallet_title", last4=sel.card_last4) + else: + title = _("payment_method_card_title", network=sel.card_network or "Card", last4=sel.card_last4) else: title = _("payment_method_generic_title", network=sel.card_network or "Payment method") added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—" @@ -817,7 +830,10 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, except Exception: pass if billing.card_last4: - title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4) + if (billing.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (billing.card_network or '') == "YooMoney": + title = _("payment_method_wallet_title", last4=billing.card_last4) + else: + title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4) else: title = _("payment_method_generic_title", network=billing.card_network or "Payment method") details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" @@ -876,7 +892,10 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings methods = await list_user_payment_methods(session, callback.from_user.id) for m in methods: if m.card_last4: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) + if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": + title = get_text("payment_method_wallet_title", last4=m.card_last4) + else: + title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) else: title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) diff --git a/locales/en.json b/locales/en.json index b66d666..bf67424 100644 --- a/locales/en.json +++ b/locales/en.json @@ -386,6 +386,7 @@ "payment_method_delete_confirm": "Remove saved payment method?", "payment_method_card_title": "💳 {network} ••••{last4}", "payment_method_generic_title": "💳 {network}", + "payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}", "payment_method_added_at": "Added: {date}", "payment_method_last_tx": "Last transaction: {date}", "payment_method_tx_history_title": "📜 Transactions history", diff --git a/locales/ru.json b/locales/ru.json index 199436d..12ee215 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -385,6 +385,7 @@ "payment_method_delete_confirm": "Удалить сохранённый способ оплаты?", "payment_method_card_title": "💳 {network} ••••{last4}", "payment_method_generic_title": "💳 {network}", + "payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}", "payment_method_added_at": "Добавлена: {date}", "payment_method_last_tx": "Последняя операция: {date}", "payment_method_tx_history_title": "📜 История операций", From b6a2bf23294b4a3fcbe847665a5fc7c16a36ea17 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:42:48 +0300 Subject: [PATCH 20/41] Implement YooMoney payment method enhancements and localization updates - Added functionality to save multiple YooMoney payment methods, marking the first entry as default. - Refactored payment method display logic to standardize the presentation of YooMoney wallet details across various handlers. - Introduced new localization strings for improved clarity in user-facing messages regarding payment methods. - Enhanced error handling during payment method operations to ensure smoother user experience. --- bot/handlers/user/subscription.py | 164 ++++++++++++++++++++++-------- locales/en.json | 2 + locales/ru.json | 2 + 3 files changed, 126 insertions(+), 42 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index aa9b4d3..5cb1de5 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -334,6 +334,20 @@ async def pay_yk_callback_handler( card_last4=display_last4, card_network=display_network, ) + # Also 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=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() @@ -617,14 +631,25 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin 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: - if m.card_last4: - if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": - title = get_text("payment_method_wallet_title", last4=m.card_last4) - else: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) - else: - title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") + 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") @@ -703,13 +728,24 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting text = _("payment_methods_title") cards = [] for m in methods: - if m.card_last4: - if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": - title = _("payment_method_wallet_title", last4=m.card_last4) - else: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) - else: - title = _("payment_method_generic_title", network=m.card_network or "Payment method") + 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") @@ -739,13 +775,24 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting methods = await list_user_payment_methods(session, callback.from_user.id) cards = [] for m in methods: - if m.card_last4: - if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": - title = _("payment_method_wallet_title", last4=m.card_last4) - else: - title = _("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) - else: - title = _("payment_method_generic_title", network=m.card_network or "Payment method") + 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}")) text = _("payment_methods_title") if not cards: @@ -775,13 +822,24 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, pm_id = parts[2] if len(parts) >= 3 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]) - if sel.card_last4: - if (sel.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (sel.card_network or '') == "YooMoney": - title = _("payment_method_wallet_title", last4=sel.card_last4) - else: - title = _("payment_method_card_title", network=sel.card_network or "Card", last4=sel.card_last4) - else: - title = _("payment_method_generic_title", network=sel.card_network or "Payment method") + 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 last_tx = "—" @@ -829,13 +887,24 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, last_tx = last_payment.created_at.strftime('%Y-%m-%d') except Exception: pass - if billing.card_last4: - if (billing.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (billing.card_network or '') == "YooMoney": - title = _("payment_method_wallet_title", last4=billing.card_last4) - else: - title = _("payment_method_card_title", network=billing.card_network or "Card", last4=billing.card_last4) - else: - title = _("payment_method_generic_title", network=billing.card_network or "Payment method") + 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: @@ -891,13 +960,24 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings cards: List[tuple] = [] methods = await list_user_payment_methods(session, callback.from_user.id) for m in methods: - if m.card_last4: - if (m.card_network or '').lower() in {"yoomoney", "yoo money", "yoo-money", "yoomoney wallet", "yoomoney кошелек", "yoomoney кошелёк"} or (m.card_network or '') == "YooMoney": - title = get_text("payment_method_wallet_title", last4=m.card_last4) - else: - title = get_text("payment_method_card_title", network=m.card_network or "Card", last4=m.card_last4) - else: - title = get_text("payment_method_generic_title", network=m.card_network or "Payment method") + 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}")) # Parse page diff --git a/locales/en.json b/locales/en.json index bf67424..fd11ac5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -387,6 +387,8 @@ "payment_method_card_title": "💳 {network} ••••{last4}", "payment_method_generic_title": "💳 {network}", "payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}", + "payment_network_card": "Card", + "payment_network_generic": "Payment method", "payment_method_added_at": "Added: {date}", "payment_method_last_tx": "Last transaction: {date}", "payment_method_tx_history_title": "📜 Transactions history", diff --git a/locales/ru.json b/locales/ru.json index 12ee215..efa4954 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -386,6 +386,8 @@ "payment_method_card_title": "💳 {network} ••••{last4}", "payment_method_generic_title": "💳 {network}", "payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}", + "payment_network_card": "Карта", + "payment_network_generic": "Способ оплаты", "payment_method_added_at": "Добавлена: {date}", "payment_method_last_tx": "Последняя операция: {date}", "payment_method_tx_history_title": "📜 История операций", From 2bc7bea29dbfe1f7d90bb3ed7348efac498f77f6 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:49:31 +0300 Subject: [PATCH 21/41] Enhance payment method history filtering and YooKassa integration - Updated the payment method history handler to support filtering by specific saved payment methods, improving user experience when viewing payment logs. - Increased the limit of recent payment logs retrieved from 10 to 30 for better visibility. - Enhanced the YooKassa service to include detailed payment method information, including card details and last four digits, improving clarity in payment history. - Refactored error handling in the YooKassa service to ensure robust fetching of payment information. --- bot/handlers/user/subscription.py | 41 +++++++++++++- bot/services/yookassa_service.py | 89 ++++++++++++++++--------------- 2 files changed, 85 insertions(+), 45 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 5cb1de5..c28288d 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -914,15 +914,52 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, @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, 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 # Simple history from payments table filtered by user from db.dal import payment_dal - payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10, offset=0) + 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] + + # If viewing a specific saved payment method, filter history by that method when possible + selected_pm_provider_id: Optional[str] = None + try: + _, _, pm_id = callback.data.split(":", 2) + if pm_id: + # pm_id is our internal method_id; map to provider id + 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) == pm_id), None) + if sel and sel.provider_payment_method_id: + selected_pm_provider_id = sel.provider_payment_method_id + except Exception: + selected_pm_provider_id = None + + if selected_pm_provider_id: + # Filter to rows we can confidently associate with the selected method + # Heuristics: + # 1) Payments with yookassa_payment_id -> fetch payment info and compare payment_method.id + # 2) For auto-renew description, it always uses default saved method; keep those too + 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 + # Fallback: auto-renew entries initiated via default method; include them + if (p.description or "").lower().startswith("auto-renewal"): + filtered.append(p) + user_payments = filtered if not user_payments: # Try to get pm_id from context to go one step back pm_id = "" diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 50d61e8..d902f79 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -206,19 +206,6 @@ class YooKassaService: logging.error( "YooKassa is not configured. Cannot get payment info.") return None - - async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: - if not self.configured: - logging.error("YooKassa is not configured. Cannot cancel payment.") - return False - try: - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa)) - logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") - return True - except Exception as e: - logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}") - return False try: logging.info( f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}" @@ -232,37 +219,40 @@ class YooKassaService: logging.info( f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" ) + pm = getattr(payment_info_yk, 'payment_method', None) + pm_payload: Dict[str, Any] = {} + if pm: + # Collect common fields, including id and hints for last4 + pm_id = getattr(pm, 'id', None) + pm_type = getattr(pm, 'type', None) + pm_title = getattr(pm, 'title', None) + account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None) + card_obj = getattr(pm, 'card', None) + last4_val = None + if card_obj and hasattr(card_obj, 'last4'): + last4_val = getattr(card_obj, 'last4') + elif isinstance(account_number, str) and len(account_number) >= 4: + last4_val = account_number[-4:] + pm_payload = { + "id": pm_id, + "type": pm_type, + "title": pm_title, + "card_last4": last4_val, + } return { - "id": - payment_info_yk.id, - "status": - payment_info_yk.status, - "paid": - payment_info_yk.paid, - "amount_value": - float(payment_info_yk.amount.value), - "amount_currency": - payment_info_yk.amount.currency, - "metadata": - payment_info_yk.metadata, - "description": - payment_info_yk.description, - "refundable": - payment_info_yk.refundable, - "created_at": - payment_info_yk.created_at.isoformat() if hasattr( - payment_info_yk.created_at, 'isoformat') else str( - payment_info_yk.created_at), - "captured_at": - payment_info_yk.captured_at.isoformat() - if payment_info_yk.captured_at and hasattr( - payment_info_yk.captured_at, 'isoformat') else None, - "payment_method_type": - payment_info_yk.payment_method.type - if payment_info_yk.payment_method else None, - "test_mode": - payment_info_yk.test - if hasattr(payment_info_yk, 'test') else None + "id": payment_info_yk.id, + "status": payment_info_yk.status, + "paid": payment_info_yk.paid, + "amount_value": float(payment_info_yk.amount.value), + "amount_currency": payment_info_yk.amount.currency, + "metadata": payment_info_yk.metadata, + "description": payment_info_yk.description, + "refundable": payment_info_yk.refundable, + "created_at": payment_info_yk.created_at.isoformat() if hasattr( + payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at), + "captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None, + "payment_method": pm_payload, + "test_mode": getattr(payment_info_yk, 'test', None), } else: logging.warning( @@ -274,3 +264,16 @@ class YooKassaService: f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}", exc_info=True) return None + + async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: + if not self.configured: + logging.error("YooKassa is not configured. Cannot cancel payment.") + return False + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa)) + logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") + return True + except Exception as e: + logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}") + return False From 2b855ed11b9dcd325f3a3ad4d97945b4e4c786af Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 18:53:58 +0300 Subject: [PATCH 22/41] Refactor payment method ID handling in subscription history - Updated the payment method history handler to improve the extraction of payment method IDs from callback data, enhancing reliability in filtering and navigation. - Changed variable names for clarity, ensuring better readability and maintainability of the code. - Improved error handling during ID extraction to prevent potential issues when no payment history is available. --- bot/handlers/user/subscription.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index c28288d..e560567 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -927,12 +927,12 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin # If viewing a specific saved payment method, filter history by that method when possible selected_pm_provider_id: Optional[str] = None try: - _, _, pm_id = callback.data.split(":", 2) - if pm_id: + split_a, split_b, split_pm_id = callback.data.split(":", 2) + if split_pm_id: # pm_id is our internal method_id; map to provider id 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) == pm_id), None) + 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 except Exception: @@ -962,12 +962,12 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin user_payments = filtered if not user_payments: # Try to get pm_id from context to go one step back - pm_id = "" + back_pm_id = "" try: - _, _, pm_id = callback.data.split(":", 2) + split_a, split_b, back_pm_id = callback.data.split(":", 2) except Exception: - pm_id = "" - back_markup = get_back_to_payment_method_details_keyboard(pm_id, current_lang, i18n) if pm_id else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + 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 # Show subscription purchase titles instead of raw provider/status @@ -979,10 +979,10 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin lines = [_format_item(p) for p in user_payments] text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) try: - _, _, pm_id = callback.data.split(":", 2) + split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) except Exception: - pm_id = "" - back_markup = get_back_to_payment_method_details_keyboard(pm_id, current_lang, i18n) if pm_id else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + split_pm_id_for_back = "" + 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) From 868c48ccb9fce44c85fe1db453b8c05bdd7b44ef Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:00:03 +0300 Subject: [PATCH 23/41] Remove outdated comments and fallback logic in payment method history handler - Eliminated unnecessary comments regarding auto-renewal payment methods to enhance code clarity. - Removed fallback logic for including auto-renew entries, streamlining the filtering process for user payments. --- bot/handlers/user/subscription.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index e560567..c7c6856 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -942,7 +942,6 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin # Filter to rows we can confidently associate with the selected method # Heuristics: # 1) Payments with yookassa_payment_id -> fetch payment info and compare payment_method.id - # 2) For auto-renew description, it always uses default saved method; keep those too filtered: List[Payment] = [] for p in user_payments: if p.provider != 'yookassa': @@ -956,9 +955,6 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin continue except Exception: pass - # Fallback: auto-renew entries initiated via default method; include them - if (p.description or "").lower().startswith("auto-renewal"): - filtered.append(p) user_payments = filtered if not user_payments: # Try to get pm_id from context to go one step back From 72eaed991a68a374c8087736f0be3fef89d10e59 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:06:20 +0300 Subject: [PATCH 24/41] Improve payment method filtering logic in subscription history handler - Added support for distinguishing between internal method IDs and direct provider payment method IDs, enhancing the accuracy of payment method filtering. - Introduced a flag to track if a payment method filter was requested, allowing for clearer handling of cases where the method cannot be resolved. - Updated error handling to ensure that empty payment histories are displayed appropriately when a filter is requested but cannot be matched. --- bot/handlers/user/subscription.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index c7c6856..e01c645 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -926,17 +926,31 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin # If viewing a specific saved payment method, filter history by that method when possible 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_id is our internal method_id; map to provider id - 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 + pm_filter_requested = True + # Two possible formats: + # - Internal method_id (digits) + # - Direct provider payment_method.id (e.g., YooKassa 'pm_...') + if split_pm_id.isdigit(): + # Map internal id to provider id + 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: + # Assume it's already a provider payment_method.id + selected_pm_provider_id = split_pm_id except Exception: selected_pm_provider_id = None + pm_filter_requested = False + + # If filter was explicitly requested but method can't be resolved (e.g., deleted), show empty history + if pm_filter_requested and not selected_pm_provider_id: + user_payments = [] if selected_pm_provider_id: # Filter to rows we can confidently associate with the selected method From d83f64c5fe7819265772c97679e6266a973f0f2c Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:21:14 +0300 Subject: [PATCH 25/41] 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. --- bot/handlers/user/__init__.py | 5 +- bot/handlers/user/subscription.py | 1083 ----------------- bot/handlers/user/subscription/__init__.py | 14 + bot/handlers/user/subscription/core.py | 246 ++++ .../user/subscription/payment_methods.py | 416 +++++++ bot/handlers/user/subscription/payments.py | 260 ++++ db/dal/user_billing_dal.py | 22 + 7 files changed, 961 insertions(+), 1085 deletions(-) delete mode 100644 bot/handlers/user/subscription.py create mode 100644 bot/handlers/user/subscription/__init__.py create mode 100644 bot/handlers/user/subscription/core.py create mode 100644 bot/handlers/user/subscription/payment_methods.py create mode 100644 bot/handlers/user/subscription/payments.py diff --git a/bot/handlers/user/__init__.py b/bot/handlers/user/__init__.py index 2aa7de7..6a37b5b 100644 --- a/bot/handlers/user/__init__.py +++ b/bot/handlers/user/__init__.py @@ -1,7 +1,8 @@ from aiogram import Router 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 promo_user 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(trial_handler.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) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py deleted file mode 100644 index e01c645..0000000 --- a/bot/handlers/user/subscription.py +++ /dev/null @@ -1,1083 +0,0 @@ -import logging -from aiogram import Router, F, types, Bot -from aiogram.filters import Command -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice -from typing import Optional, Dict, Any, Union, List -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 -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_methods_list_keyboard, get_back_to_payment_methods_keyboard, get_back_to_payment_method_details_keyboard) -from bot.services.yookassa_service import YooKassaService -from db.dal import user_billing_dal -from bot.services.stars_service import StarsService -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.subscription_service import SubscriptionService -from bot.services.panel_api_service import PanelApiService -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, Payment - -router = Router(name="user_subscription_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.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_stars:")) -async def pay_stars_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - session: AsyncSession, bot: Bot, stars_service: StarsService): - 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: - _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(price_str) - except (ValueError, IndexError): - logging.error(f"Invalid pay_stars 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) - - payment_id = await stars_service.create_invoice( - session, user_id, months, stars_price, payment_description) - if payment_id is None: - await callback.message.edit_text(get_text("error_payment_gateway")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - 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"): - # If YooKassa already provided a payment_method (rare on redirect), store it - 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"}: - # Normalize wallet display name to avoid leaking full account from title - 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, - ) - # Also 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=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 - - -@router.callback_query(F.data.startswith("pay_crypto:")) -async def pay_crypto_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - cryptopay_service: CryptoPayService, 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 cryptopay_service or not cryptopay_service.configured: - await callback.message.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, amount_str = data_payload.split(":") - months = int(months_str) - amount_val = float(amount_str) - except (ValueError, IndexError): - logging.error(f"Invalid pay_crypto 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 - description = get_text("payment_description_subscription", months=months) - - invoice_url = await cryptopay_service.create_invoice( - session, user_id, months, amount_val, description) - if invoice_url: - await callback.message.edit_text( - get_text("payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), - disable_web_page_preview=False, - ) - else: - await callback.message.edit_text(get_text("error_payment_gateway")) - try: - await callback.answer() - except Exception: - pass - - -@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: - 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 - ) - # Auto-renew toggle hint and Tribute notice - tribute_hint = "" - if active.get("status_from_panel", "").lower() == "active": - # Try to infer provider; fetch local sub for flags - # NOTE: Lightweight lookup by user_id - local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) - auto_renew_state = None - if local_sub: - auto_renew_state = local_sub.auto_renew_enabled - 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) - if link: - tribute_hint = "\n\n" + get_text("subscription_tribute_notice_with_link", link=link) - else: - tribute_hint = "\n\n" + 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") - ) - ) - # Build markup with auto-renew toggle and payment methods if available - base_markup = get_back_to_main_menu_markup(current_lang, i18n) - kb = base_markup.inline_keyboard - try: - if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': - toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") - kb = [[InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}")]] + kb - # Add payment methods manage entry point - kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb - except Exception: - 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: - 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 - - # Fetch subscription by ID directly - 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 - # Refresh panel info screen - await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) - - -@router.callback_query(F.data == "pm:manage") -async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - # Build and show the paginated list directly (page 0) - 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 - - # Create a minimal binding payment (1 RUB) with save_payment_method - metadata = { - "user_id": str(callback.from_user.id), - "bind_only": "1", - } - resp = await yookassa_service.create_payment( - amount=1.00, - currency="RUB", - description="Bind card", - metadata=metadata, - receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, - save_payment_method=True, - capture=False, - bind_only=True, - ) - if not resp or not resp.get("confirmation_url"): - await callback.answer(_("error_payment_gateway"), show_alert=True) - return - await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.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 - # Try to parse specific method id for multi-card deletion - parts = callback.data.split(":", 2) - pm_id_raw = parts[2] if len(parts) >= 3 else "" - deleted = False - # Attempt multi-card deletion first - try: - if pm_id_raw and pm_id_raw.isdigit(): - from db.dal.user_billing_dal import delete_user_payment_method, list_user_payment_methods - deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw)) - await session.commit() - # Build updated list - 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") - # Prepend status message to title - 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() - deleted = False - - # Fallback: legacy single-card storage deletion - try: - deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) - await session.commit() - except Exception: - await session.rollback() - deleted = False - - msg = _("payment_method_deleted_success") if deleted else _("error_try_again") - # After legacy deletion, route user to list (which will be empty) for consistency - from db.dal.user_billing_dal import list_user_payment_methods - methods = await list_user_payment_methods(session, callback.from_user.id) - 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}")) - text = _("payment_methods_title") - if not cards: - text += "\n\n" + _("payment_method_none") - 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 - - -@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: - # 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 - parts = callback.data.split(":", 2) - pm_id = parts[2] if len(parts) >= 3 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]) - 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 - 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 - 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 - - # Simple history from payments table filtered by user - from db.dal import payment_dal - payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0) - user_payments = [p for p in payments if p.user_id == callback.from_user.id] - - # If viewing a specific saved payment method, filter history by that method when possible - 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 - # Two possible formats: - # - Internal method_id (digits) - # - Direct provider payment_method.id (e.g., YooKassa 'pm_...') - if split_pm_id.isdigit(): - # Map internal id to provider id - 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: - # Assume it's already a provider payment_method.id - selected_pm_provider_id = split_pm_id - except Exception: - selected_pm_provider_id = None - pm_filter_requested = False - - # If filter was explicitly requested but method can't be resolved (e.g., deleted), show empty history - if pm_filter_requested and not selected_pm_provider_id: - user_payments = [] - - if selected_pm_provider_id: - # Filter to rows we can confidently associate with the selected method - # Heuristics: - # 1) Payments with yookassa_payment_id -> fetch payment info and compare payment_method.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: - # Try to get pm_id from context to go one step back - 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 - # 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) - try: - split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) - except Exception: - split_pm_id_for_back = "" - 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 - - # 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: - 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}")) - - # Parse page - 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 - - -@router.pre_checkout_query() -async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery): - await pre_checkout_query.answer(ok=True) - - -@router.message(F.successful_payment) -async def stars_successful_payment_handler( - message: types.Message, settings: Settings, i18n_data: dict, - session: AsyncSession, stars_service: StarsService): - sp = message.successful_payment - if not sp or sp.currency != "XTR": - return - - payload = sp.invoice_payload or "" - try: - payment_id_str, months_str = payload.split(":") - payment_db_id = int(payment_id_str) - months = int(months_str) - except (ValueError, IndexError): - logging.error(f"Invalid invoice payload for stars payment: {payload}") - return - - stars_amount = sp.total_amount - await stars_service.process_successful_payment( - session, message, payment_db_id, months, stars_amount, i18n_data) - - -@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) diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py new file mode 100644 index 0000000..2d7f70a --- /dev/null +++ b/bot/handlers/user/subscription/__init__.py @@ -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) + + diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py new file mode 100644 index 0000000..58cd8a1 --- /dev/null +++ b/bot/handlers/user/subscription/core.py @@ -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) + + diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py new file mode 100644 index 0000000..13972c4 --- /dev/null +++ b/bot/handlers/user/subscription/payment_methods.py @@ -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 + + diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py new file mode 100644 index 0000000..be09f26 --- /dev/null +++ b/bot/handlers/user/subscription/payments.py @@ -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 + + diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 319be82..a6c1039 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -140,3 +140,25 @@ async def delete_user_payment_method(session: AsyncSession, user_id: int, method await session.delete(method) await session.flush() 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 From f11ec6a676509c4d839ac31cb8da263ec6cae8e8 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:29:13 +0300 Subject: [PATCH 26/41] Add re-exports for backward compatibility in subscription module - Re-exported commonly used entrypoints from the core module to maintain backward compatibility. - Ensured that existing functionality remains accessible after recent refactoring efforts in the subscription handling codebase. --- bot/handlers/user/subscription/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py index 2d7f70a..a4582aa 100644 --- a/bot/handlers/user/subscription/__init__.py +++ b/bot/handlers/user/subscription/__init__.py @@ -11,4 +11,7 @@ router.include_router(core.router) router.include_router(payments.router) router.include_router(payment_methods.router) +# Re-export commonly used entrypoints for backward compatibility +from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401 + From 807e3dadd32583c10ac3083edf5b2606f479deb1 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:52:57 +0300 Subject: [PATCH 27/41] Update database setup to include simple migrations and enhance .gitignore - Added the `run_simple_migrations` function call in the database initialization process to ensure any missing columns are added during setup. - Updated the .gitignore file to exclude the `models_old.py` file, improving project cleanliness. --- .gitignore | 1 + db/database_setup.py | 3 ++ db/migrator.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 db/migrator.py diff --git a/.gitignore b/.gitignore index fd0a62f..0a8d3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__/ *.pid locales/ru_backup.json locales/en_backup.json +db/models_old.py diff --git a/db/database_setup.py b/db/database_setup.py index 0b48c3f..4c1fbf8 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker from config.settings import Settings from .models import Base +from .migrator import run_simple_migrations async_engine = None @@ -62,6 +63,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # Run lightweight, idempotent migrations to add any missing columns + await conn.run_sync(run_simple_migrations) logging.info( "PostgreSQL database initialized/checked successfully using SQLAlchemy." ) diff --git a/db/migrator.py b/db/migrator.py new file mode 100644 index 0000000..3f37cc0 --- /dev/null +++ b/db/migrator.py @@ -0,0 +1,66 @@ +import logging +from typing import Set + +from sqlalchemy import inspect, text +from sqlalchemy.engine import Connection + +from .models import Base + + +def _add_missing_columns(connection: Connection) -> None: + inspector = inspect(connection) + metadata = Base.metadata + + existing_tables: Set[str] = set(inspector.get_table_names()) + + for table in metadata.tables.values(): + table_name = table.name + if table_name not in existing_tables: + # Tables are created elsewhere via create_all; skip here. + continue + + existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)} + + for desired_column in table.columns: + if desired_column.name in existing_columns: + continue + + # Build ADD COLUMN DDL + preparer = connection.dialect.identifier_preparer + table_quoted = preparer.format_table(table) + column_name_quoted = preparer.quote(desired_column.name) + column_type_sql = desired_column.type.compile(dialect=connection.dialect) + + default_clause = "" + server_default = getattr(desired_column, "server_default", None) + if server_default is not None and getattr(server_default, "arg", None) is not None: + try: + compiled_default = str( + server_default.arg.compile(dialect=connection.dialect) + ) + default_clause = f" DEFAULT {compiled_default}" + except Exception: # best-effort + pass + + # For safety, add new columns as NULLable to avoid failures on existing rows + # If strict NOT NULL is needed, it can be enforced manually later. + ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}" + + logging.info( + f"Migrator: adding missing column {desired_column.name} to table {table_name}" + ) + connection.execute(text(ddl)) + + +def run_simple_migrations(connection: Connection) -> None: + """ + Run lightweight, idempotent migrations: + - Ensure missing columns are added to existing tables to match models in db/models.py + Note: Table creation is handled separately via Base.metadata.create_all. + """ + try: + _add_missing_columns(connection) + logging.info("Migrator: schema synchronized (columns added as needed).") + except Exception as e: + logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True) + raise From b7a723a0888162ba91eeb99e502a3075443339cf Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 20:03:47 +0300 Subject: [PATCH 28/41] Implement YooKassa autopayments feature toggle across payment and subscription handlers - Introduced a global setting for enabling or disabling YooKassa autopayments, allowing for better control over payment method management and subscription renewals. - Updated various handlers to check the autopayments setting before executing payment-related logic, ensuring that features are only available when enabled. - Enhanced error handling to notify users when autopayments are disabled, improving user experience and clarity in payment operations. - Refactored receipt generation logic to derive fields based on the autopayments setting, streamlining configuration management. --- bot/handlers/user/payment.py | 4 +- bot/handlers/user/subscription/core.py | 5 ++- .../user/subscription/payment_methods.py | 42 +++++++++++++++++++ bot/handlers/user/subscription/payments.py | 3 +- bot/services/subscription_service.py | 3 ++ bot/services/yookassa_service.py | 4 +- config/settings.py | 16 +++++++ 7 files changed, 70 insertions(+), 7 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 5b5cbe8..2efcfbc 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -138,7 +138,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, # Try to capture and save payment method for future charges if available try: payment_method = payment_info_from_webhook.get("payment_method") - if isinstance(payment_method, dict) and payment_method.get("saved", False): + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False): pm_id = payment_method.get("id") pm_type = payment_method.get("type") title = payment_method.get("title") @@ -484,7 +484,7 @@ async def yookassa_webhook_route(request: web.Request): elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: # Bind-only flow: save method and cancel auth if metadata has bind_only metadata = payment_dict_for_processing.get("metadata", {}) or {} - if metadata.get("bind_only") == "1": + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1": try: user_id_str = metadata.get("user_id") if user_id_str and user_id_str.isdigit(): diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 58cd8a1..32e6b64 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -151,7 +151,7 @@ async def my_subscription_command_handler( 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": + if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -163,7 +163,8 @@ async def my_subscription_command_handler( ) ] ] + kb - kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb except Exception: pass markup = InlineKeyboardMarkup(inline_keyboard=kb) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 13972c4..5dec974 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -22,6 +22,13 @@ router = Router(name="user_subscription_payment_methods_router") 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key from db.dal.user_billing_dal import list_user_payment_methods @@ -68,6 +75,13 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} @@ -95,6 +109,13 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = 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 "" @@ -109,6 +130,13 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = 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 "" @@ -176,6 +204,13 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = 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) @@ -290,6 +325,13 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, 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") + if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + try: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await callback.answer(_("error_service_unavailable"), show_alert=True) + except Exception: + pass + return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key from db.dal import payment_dal diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index be09f26..482ce22 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -164,7 +164,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti description=payment_description, metadata=yookassa_metadata, receipt_email=receipt_email_for_yk, - save_payment_method=True, + # Save method only when autopayments are enabled + save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)), ) if payment_response_yk and payment_response_yk.get("confirmation_url"): diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 4165cc6..fdbfc72 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -789,6 +789,9 @@ class SubscriptionService: """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" if not sub.auto_renew_enabled: return True + # If autopayments are disabled globally, skip charging attempts + if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + return True if sub.provider == "tribute": # Tribute is paid externally; we do not auto-charge here return True diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index d902f79..6df92af 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -136,9 +136,9 @@ class YooKassaService: "vat_code": str(self.settings.YOOKASSA_VAT_CODE), "payment_mode": - self.settings.YOOKASSA_PAYMENT_MODE, + getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE), "payment_subject": - self.settings.YOOKASSA_PAYMENT_SUBJECT + getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT) }] receipt_data_dict: Dict[str, Any] = { diff --git a/config/settings.py b/config/settings.py index e65cdea..21878b4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -30,8 +30,11 @@ class Settings(BaseSettings): YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None) YOOKASSA_VAT_CODE: int = Field(default=1) + # Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") + # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) + YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False) WEBHOOK_BASE_URL: Optional[str] = None @@ -233,6 +236,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return None + # Computed YooKassa receipt fields based on recurring toggle + @computed_field + @property + def yk_receipt_payment_mode(self) -> str: + # If autopayments are enabled, use service; otherwise full prepayment + return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment" + + @computed_field + @property + def yk_receipt_payment_subject(self) -> str: + # If autopayments are enabled, use full_payment; otherwise payment + return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment" + @computed_field @property def subscription_options(self) -> Dict[int, float]: From 6f3b727cc7905bd821db6195197ad0071cc40d8e Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 20:08:37 +0300 Subject: [PATCH 29/41] Update .env.example to include YooKassa autopayments configuration - Added new environment variables for enabling/disabling YooKassa autopayments, enhancing payment method management. - Updated comments to clarify the relationship between autopayments and receipt fields, improving documentation for developers. --- .env.example | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index ac2c427..7505ffc 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,14 @@ YOOKASSA_SECRET_KEY=your_secret_key YOOKASSA_RETURN_URL=https://t.me/your_bot YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com YOOKASSA_VAT_CODE=1 -YOOKASSA_PAYMENT_MODE=full_prepayment -YOOKASSA_PAYMENT_SUBJECT=payment + +# Recurring payments master toggle for YooKassa +# false: one-time payments only; true: enable saving cards, manage methods UI, auto-renew +YOOKASSA_AUTOPAYMENTS_ENABLED=false + +# Receipt fields are set automatically based on YOOKASSA_AUTOPAYMENTS_ENABLED: +# - when false: YOOKASSA_PAYMENT_MODE=full_prepayment; YOOKASSA_PAYMENT_SUBJECT=payment +# - when true: YOOKASSA_PAYMENT_MODE=service; YOOKASSA_PAYMENT_SUBJECT=full_payment # CryptoBot Payment Gateway Configuration CRYPTOPAY_TOKEN= From 8f603ad51ce1fa0e678961803eb62be6dba51751 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 20:20:27 +0300 Subject: [PATCH 30/41] Enhance subscription command handler with dynamic inline keyboard options - Added support for a mini-app connect button in the subscription command handler, allowing users to access additional features if the URL is configured. - Implemented an auto-renew toggle button and payment methods management button, improving user interaction based on subscription status and settings. - Refactored inline keyboard construction to prepend new buttons above the existing markup, enhancing the user experience in subscription management. --- bot/handlers/user/subscription/core.py | 38 +++++++++++++++++++------- bot/keyboards/inline/user_keyboards.py | 18 ++++-------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 32e6b64..cb84d1b 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -1,7 +1,7 @@ import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo from typing import Optional, Union from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession @@ -151,20 +151,38 @@ async def my_subscription_command_handler( kb = base_markup.inline_keyboard try: local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) + # Build rows to prepend above the base "back" markup + prepend_rows = [] + + # 1) Mini-app connect button on top if enabled + if settings.SUBSCRIPTION_MINI_APP_URL: + prepend_rows.append([ + InlineKeyboardButton( + text=get_text("connect_button"), + web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), + ) + ]) + + # 2) Auto-renew toggle (if supported and not tribute) if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): 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 + prepend_rows.append([ + InlineKeyboardButton( + text=toggle_text, + callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", + ) + ]) + + # 3) Payment methods management (when autopayments enabled) if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): - kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb + prepend_rows.append([ + InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage") + ]) + + if prepend_rows: + kb = prepend_rows + kb except Exception: pass markup = InlineKeyboardMarkup(inline_keyboard=kb) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 4ed92b4..6fd2dae 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -21,20 +21,12 @@ def get_main_menu_inline_keyboard( builder.row( InlineKeyboardButton(text=_(key="menu_subscribe_inline"), callback_data="main_action:subscribe")) - if settings.SUBSCRIPTION_MINI_APP_URL: - builder.row( - InlineKeyboardButton( - text=_(key="menu_my_subscription_inline"), - web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), - ) - ) - else: - builder.row( - InlineKeyboardButton( - text=_(key="menu_my_subscription_inline"), - callback_data="main_action:my_subscription", - ) + builder.row( + InlineKeyboardButton( + text=_(key="menu_my_subscription_inline"), + callback_data="main_action:my_subscription", ) + ) referral_button = InlineKeyboardButton( text=_(key="menu_referral_inline"), From d7d8409919916f8443f0a672d71e8749d9b7847c Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 20:46:20 +0300 Subject: [PATCH 31/41] Refactor subscription command handler to improve user experience - Updated the subscription command handler to dynamically adjust inline keyboard options based on user subscription status. - Enhanced the mini-app connect button functionality, allowing access to additional features when the URL is configured. - Improved the layout of inline keyboard buttons to prioritize new options, streamlining user interaction in subscription management. --- LICENSE | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..27c0821 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 machka-pasla + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file From c6292a1c8ed8de5640b0bd26f91a45933c2b8941 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 14:53:32 +0300 Subject: [PATCH 32/41] Add ad campaign management features and enhance user attribution - Introduced ad campaign management functionality, including the ability to create campaigns and attribute users based on ad parameters. - Updated user command handlers to process new ad-related parameters and log user interactions with ad campaigns. - Enhanced admin interfaces with new keyboard options for managing ads and displaying campaign information. - Added database models for ad campaigns and attributions, improving data management for advertising features. - Updated localization files to support new ad-related strings in both English and Russian. --- bot/handlers/admin/__init__.py | 2 + bot/handlers/admin/ads.py | 163 ++++++++++++++++++++++++ bot/handlers/user/start.py | 23 +++- bot/handlers/user/trial_handler.py | 15 +++ bot/keyboards/inline/admin_keyboards.py | 15 +++ bot/states/admin_states.py | 5 + db/dal/__init__.py | 21 +++ db/dal/ad_dal.py | 129 +++++++++++++++++++ db/models.py | 32 +++++ locales/en.json | 16 ++- locales/ru.json | 16 ++- 11 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 bot/handlers/admin/ads.py create mode 100644 db/dal/__init__.py create mode 100644 db/dal/ad_dal.py diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index ecf8b9f..6f5dc6d 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -8,6 +8,7 @@ from . import statistics from . import sync_admin from . import logs_admin from . import payments +from . import ads admin_router_aggregate = Router(name="admin_features_router") @@ -19,5 +20,6 @@ admin_router_aggregate.include_router(statistics.router) admin_router_aggregate.include_router(sync_admin.router) admin_router_aggregate.include_router(logs_admin.router) admin_router_aggregate.include_router(payments.router) +admin_router_aggregate.include_router(ads.router) __all__ = ("admin_router_aggregate", ) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py new file mode 100644 index 0000000..88f5b1c --- /dev/null +++ b/bot/handlers/admin/ads.py @@ -0,0 +1,163 @@ +import logging +from aiogram import Router, F, types +from aiogram.fsm.context import FSMContext +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from db.dal import ad_dal + +router = Router(name="admin_ads_router") + + +@router.callback_query(F.data == "admin_action:ads") +async def show_ads_menu(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 + + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + campaigns = await ad_dal.list_campaigns(session) + if not campaigns: + text = _("admin_ads_empty") + else: + text_lines = [_("admin_ads_header")] + for camp in campaigns: + try: + stats = await ad_dal.get_campaign_stats(session, camp.ad_campaign_id) + except Exception as e_stats: + logging.error(f"Failed to calc stats for campaign {camp.ad_campaign_id}: {e_stats}") + stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0} + text_lines.append( + _( + "admin_ads_item", + id=camp.ad_campaign_id, + source=camp.source, + start_param=camp.start_param, + cost=f"{camp.cost:.2f}", + active=_("csv_yes") if camp.is_active else _("csv_no"), + starts=stats["starts"], + trials=stats["trials"], + payers=stats["payers"], + revenue=f"{stats['revenue']:.2f}", + ) + ) + text = "\n\n".join(text_lines) + + from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard + reply_markup = get_ads_menu_keyboard(i18n, current_lang) + await callback.message.edit_text(text, reply_markup=reply_markup) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "admin_action:ads_create") +async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict): + from bot.states.admin_states import AdminStates + 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 + + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + await state.set_state(AdminStates.waiting_for_ad_source) + await callback.message.edit_text(_("admin_ads_create_source_prompt")) + try: + await callback.answer() + except Exception: + pass + + +@router.message(F.text, state="*") +async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession): + from bot.states.admin_states import AdminStates + current_state = await state.get_state() + if current_state not in ( + AdminStates.waiting_for_ad_source.state, + AdminStates.waiting_for_ad_start_param.state, + AdminStates.waiting_for_ad_cost.state, + ): + return + + 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 + + if current_state == AdminStates.waiting_for_ad_source.state: + source = message.text.strip() + if not source or len(source) > 64: + await message.answer(_("admin_ads_invalid_source")) + return + await state.update_data(ad_source=source) + await state.set_state(AdminStates.waiting_for_ad_start_param) + await message.answer(_("admin_ads_create_start_param_prompt")) + return + + if current_state == AdminStates.waiting_for_ad_start_param.state: + start_param = message.text.strip() + # Allow alnum underscore dash only + import re as _re + if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param): + await message.answer(_("admin_ads_invalid_start_param")) + return + await state.update_data(ad_start_param=start_param) + await state.set_state(AdminStates.waiting_for_ad_cost) + await message.answer(_("admin_ads_create_cost_prompt")) + return + + if current_state == AdminStates.waiting_for_ad_cost.state: + text = message.text.replace(",", ".").strip() + try: + cost = float(text) + if cost < 0 or cost > 1e8: + raise ValueError() + except Exception: + await message.answer(_("admin_ads_invalid_cost")) + return + + data = await state.get_data() + try: + campaign = await ad_dal.create_campaign( + session, + source=data.get("ad_source", "unknown"), + start_param=data.get("ad_start_param", "NA"), + cost=cost, + ) + await session.commit() + except ValueError as ve: + await session.rollback() + if str(ve) == "ad_campaign_start_param_exists": + await message.answer(_("admin_ads_start_param_exists")) + else: + await message.answer(_("error_occurred_try_again")) + return + except Exception as e: + await session.rollback() + logging.error(f"Failed to create ad campaign: {e}", exc_info=True) + await message.answer(_("error_occurred_try_again")) + return + + await state.clear() + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + await message.answer( + _( + "admin_ads_created_success", + id=campaign.ad_campaign_id, + source=campaign.source, + start_param=campaign.start_param, + cost=f"{campaign.cost:.2f}", + ) + ) + # Offer back to ads menu + from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard + await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang)) + + diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 0c25f17..294b055 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -118,6 +118,7 @@ async def send_main_menu(target_event: Union[types.Message, @router.message(CommandStart()) @router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) +@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match"))) async def start_command_handler(message: types.Message, state: FSMContext, settings: Settings, @@ -125,7 +126,8 @@ async def start_command_handler(message: types.Message, subscription_service: SubscriptionService, session: AsyncSession, ref_match: Optional[re.Match] = None, - promo_match: Optional[re.Match] = None): + promo_match: Optional[re.Match] = None, + ad_param_match: Optional[re.Match] = None): await state.clear() current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -137,6 +139,7 @@ async def start_command_handler(message: types.Message, referred_by_user_id: Optional[int] = None promo_code_to_apply: Optional[str] = None + ad_start_param: Optional[str] = None if ref_match: potential_referrer_id = int(ref_match.group(1)) @@ -145,6 +148,9 @@ async def start_command_handler(message: types.Message, elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") + elif ad_param_match: + ad_start_param = ad_param_match.group(1) + logging.info(f"User {user_id} started with ad start param: {ad_start_param}") db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -217,6 +223,21 @@ async def start_command_handler(message: types.Message, f"Failed to update existing user {user_id} in session: {e_update}", exc_info=True) + # Attribute user to ad campaign if start param provided + if ad_start_param: + try: + from db.dal import ad_dal as _ad_dal + campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param) + if campaign and campaign.is_active: + await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id) + await session.commit() + except Exception as e_attr: + logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}") + try: + await session.rollback() + except Exception: + pass + # Send welcome message if not disabled if not settings.DISABLE_WELCOME_MESSAGE: await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index b03966c..c3ad911 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -113,6 +113,14 @@ async def request_trial_confirmation_handler( # Send notification to admin about new trial notification_service = NotificationService(callback.bot, settings, i18n) await notification_service.notify_trial_activation(user_id, end_date_obj) + # Mark ad attribution trial if exists + try: + from db.dal import ad_dal as _ad_dal + await _ad_dal.mark_trial_activated(session, user_id) + await session.commit() + except Exception as e_mark: + await session.rollback() + logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}") else: message_key_from_service = ( activation_result.get("message_key", "trial_activation_failed") @@ -298,6 +306,13 @@ async def confirm_activate_trial_handler( if activation_result and activation_result.get("activated") and end_date_obj: notification_service = NotificationService(callback.bot, settings, i18n) await notification_service.notify_trial_activation(user_id, end_date_obj) + try: + from db.dal import ad_dal as _ad_dal + await _ad_dal.mark_trial_activated(session, user_id) + await session.commit() + except Exception as e_mark: + await session.rollback() + logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}") @router.callback_query(F.data == "main_action:cancel_trial") diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 3fa2ccc..85677dd 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -25,6 +25,10 @@ def get_admin_panel_keyboard(i18n_instance, lang: str, builder.button(text=_(key="admin_promo_marketing_section"), callback_data="admin_section:promo_marketing") + # Реклама + builder.button(text=_(key="admin_ads_section", default="📈 Реклама"), + callback_data="admin_action:ads") + # Системные функции builder.button(text=_(key="admin_system_functions_section"), callback_data="admin_section:system_functions") @@ -116,6 +120,17 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar return builder.as_markup() +def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), + callback_data="admin_action:ads_create") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1, 1) + return builder.as_markup() + + def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 330e53e..6db5421 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -28,3 +28,8 @@ class AdminStates(StatesGroup): waiting_for_user_search = State() waiting_for_subscription_days_to_add = State() waiting_for_direct_message_to_user = State() + + # Ads campaigns + waiting_for_ad_source = State() + waiting_for_ad_start_param = State() + waiting_for_ad_cost = State() diff --git a/db/dal/__init__.py b/db/dal/__init__.py new file mode 100644 index 0000000..8df65b5 --- /dev/null +++ b/db/dal/__init__.py @@ -0,0 +1,21 @@ +from . import user_dal +from . import payment_dal +from . import subscription_dal +from . import promo_code_dal +from . import panel_sync_dal +from . import message_log_dal +from . import user_billing_dal +from . import ad_dal + +__all__ = ( + "user_dal", + "payment_dal", + "subscription_dal", + "promo_code_dal", + "panel_sync_dal", + "message_log_dal", + "user_billing_dal", + "ad_dal", +) + + diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py new file mode 100644 index 0000000..85f820c --- /dev/null +++ b/db/dal/ad_dal.py @@ -0,0 +1,129 @@ +import logging +from typing import Optional, List, Dict, Any, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from sqlalchemy import update, delete, func, and_ + +from ..models import AdCampaign, AdAttribution, Payment + + +async def create_campaign( + session: AsyncSession, *, source: str, start_param: str, cost: float +) -> AdCampaign: + existing = await get_campaign_by_start_param(session, start_param) + if existing: + raise ValueError("ad_campaign_start_param_exists") + + campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost)) + session.add(campaign) + await session.flush() + await session.refresh(campaign) + logging.info( + f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" + ) + return campaign + + +async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]: + stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]: + clean = start_param.strip() + stmt = select(AdCampaign).where(AdCampaign.start_param == clean) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]: + stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()) + if only_active: + stmt = stmt.where(AdCampaign.is_active == True) + result = await session.execute(stmt) + return result.scalars().all() + + +async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool: + stmt = ( + update(AdCampaign) + .where(AdCampaign.ad_campaign_id == campaign_id) + .values(is_active=is_active) + ) + result = await session.execute(stmt) + return result.rowcount > 0 + + +async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution: + existing = await get_attribution_for_user(session, user_id) + if existing: + return existing + attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id) + session.add(attrib) + await session.flush() + await session.refresh(attrib) + logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}") + return attrib + + +async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]: + stmt = select(AdAttribution).where(AdAttribution.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool: + stmt = ( + update(AdAttribution) + .where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None))) + .values(trial_activated_at=func.now()) + ) + result = await session.execute(stmt) + return result.rowcount > 0 + + +async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]: + # Starts (attributed users) + starts_stmt = select(func.count(AdAttribution.user_id)).where( + AdAttribution.ad_campaign_id == campaign_id + ) + starts = (await session.execute(starts_stmt)).scalar() or 0 + + # Trials + trials_stmt = select(func.count(AdAttribution.user_id)).where( + and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None)) + ) + trials = (await session.execute(trials_stmt)).scalar() or 0 + + # Payers (unique users with succeeded payments) + payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where( + and_( + Payment.status == "succeeded", + Payment.user_id.in_( + select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id) + ), + ) + ) + payers = (await session.execute(payers_stmt)).scalar() or 0 + + # Revenue sum + revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where( + and_( + Payment.status == "succeeded", + Payment.user_id.in_( + select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id) + ), + ) + ) + revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0) + + return { + "starts": int(starts), + "trials": int(trials), + "payers": int(payers), + "revenue": revenue, + } + + diff --git a/db/models.py b/db/models.py index 8f661e6..d33dfdf 100644 --- a/db/models.py +++ b/db/models.py @@ -227,3 +227,35 @@ class PanelSyncStatus(Base): subscriptions_synced = Column(Integer, default=0) __table_args__ = (UniqueConstraint('id'), ) + + +class AdCampaign(Base): + __tablename__ = "ad_campaigns" + + ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String, nullable=False, index=True) + start_param = Column(String, nullable=False, unique=True, index=True) + cost = Column(Float, nullable=False, default=0.0) + is_active = Column(Boolean, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + attributions = relationship( + "AdAttribution", + back_populates="campaign", + cascade="all, delete-orphan", + ) + + def __repr__(self): + return f"" + + +class AdAttribution(Base): + __tablename__ = "ad_attributions" + + user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True) + ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True) + first_start_at = Column(DateTime(timezone=True), server_default=func.now()) + trial_activated_at = Column(DateTime(timezone=True), nullable=True) + + user = relationship("User") + campaign = relationship("AdCampaign", back_populates="attributions") diff --git a/locales/en.json b/locales/en.json index fd11ac5..9275745 100644 --- a/locales/en.json +++ b/locales/en.json @@ -406,5 +406,19 @@ "error_creating_payment_record": "Error creating payment record. Please try again later.", "error_payment_gateway_link_failed": "Error creating payment link. Please try again later.", "status_active": "Active", - "status_inactive": "Inactive" + "status_inactive": "Inactive", + "admin_ads_section": "📈 Ads", + "admin_ads_header": "📈 Ad Campaigns:", + "admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.", + "admin_ads_item": "ID: {id}\nSource: {source}\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB", + "admin_ads_create_button": "➕ Create campaign", + "admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):", + "admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA", + "admin_ads_create_cost_prompt": "Enter campaign cost (RUB):", + "admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.", + "admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).", + "admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.", + "admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.", + "admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB", + "admin_ads_back_to_menu_hint": "Done. Back to Ads section:" } diff --git a/locales/ru.json b/locales/ru.json index efa4954..990fdea 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -405,5 +405,19 @@ "error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.", "error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.", "status_active": "Активна", - "status_inactive": "Неактивна" + "status_inactive": "Неактивна", + "admin_ads_section": "📈 Реклама", + "admin_ads_header": "📈 Рекламные кампании:", + "admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.", + "admin_ads_item": "ID: {id}\nИсточник: {source}\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB", + "admin_ads_create_button": "➕ Создать кампанию", + "admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):", + "admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA", + "admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):", + "admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.", + "admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).", + "admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.", + "admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.", + "admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB", + "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:" } From d94430c602492fbb1259d1ed32d7b8f3636bc3b5 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 14:55:54 +0300 Subject: [PATCH 33/41] Enhance ad creation flow with state-based filtering - Introduced StateFilter to the ads creation flow, ensuring that messages are processed only when the bot is in specific admin states. - Improved the organization of imports by removing redundant imports and clarifying state management for ad-related interactions. --- bot/handlers/admin/ads.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index 88f5b1c..7ba5e6b 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -1,5 +1,6 @@ import logging from aiogram import Router, F, types +from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -7,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings from bot.middlewares.i18n import JsonI18n from db.dal import ad_dal +from bot.states.admin_states import AdminStates router = Router(name="admin_ads_router") @@ -76,9 +78,15 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set pass -@router.message(F.text, state="*") +@router.message( + StateFilter( + AdminStates.waiting_for_ad_source, + AdminStates.waiting_for_ad_start_param, + AdminStates.waiting_for_ad_cost, + ), + F.text, +) async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession): - from bot.states.admin_states import AdminStates current_state = await state.get_state() if current_state not in ( AdminStates.waiting_for_ad_source.state, From 7fa670c1e85a3e37084728f518287cd7b8e6300a Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 14:58:20 +0300 Subject: [PATCH 34/41] Add ads menu handler to admin panel actions - Introduced a new action for displaying the ads menu in the admin panel, enhancing the admin interface for ad management. - Improved the organization of imports related to ad handling, ensuring better modularity in the codebase. --- bot/handlers/admin/common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index 8204404..677630b 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -127,6 +127,9 @@ async def admin_panel_actions_callback_handler( from . import payments as admin_payments_handlers await admin_payments_handlers.view_payments_handler( callback, i18n_data, settings, session) + elif action == "ads": + from . import ads as admin_ads_handlers + await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session) elif action == "main": try: await callback.message.edit_text( From 81155c9151cff9e80d01c3f3c0dbf9e8712673b9 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 15:00:47 +0300 Subject: [PATCH 35/41] Add ads creation action to admin panel - Introduced a new action for starting the ad creation process in the admin panel, enhancing ad management capabilities. - Organized imports related to ad handling for better modularity and clarity in the codebase. --- bot/handlers/admin/common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index 677630b..996cd83 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -130,6 +130,9 @@ async def admin_panel_actions_callback_handler( elif action == "ads": from . import ads as admin_ads_handlers await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session) + elif action == "ads_create": + from . import ads as admin_ads_handlers + await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data) elif action == "main": try: await callback.message.edit_text( From 8113908a9844cd1091b30ac9de6970167e042729 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 15:08:52 +0300 Subject: [PATCH 36/41] Implement pagination and detailed views for ad campaigns in admin panel - Added pagination support for the ads list, allowing admins to navigate through multiple pages of campaigns. - Introduced detailed views for individual ad campaigns, displaying comprehensive statistics and information. - Enhanced the database access layer with new methods for counting and listing campaigns with pagination. - Updated localization files to include new strings for the ads overview and campaign details. - Improved the inline keyboard structure for better navigation within the ads management interface. --- bot/handlers/admin/ads.py | 124 ++++++++++++++++++------ bot/keyboards/inline/admin_keyboards.py | 61 ++++++++++++ db/dal/ad_dal.py | 35 +++++++ locales/en.json | 5 +- locales/ru.json | 5 +- 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index 7ba5e6b..a16e4ac 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -13,6 +13,9 @@ from bot.states.admin_states import AdminStates router = Router(name="admin_ads_router") +PAGE_SIZE = 5 + + @router.callback_query(F.data == "admin_action:ads") async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -23,35 +26,23 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_ await callback.answer("Language error.", show_alert=True) return - campaigns = await ad_dal.list_campaigns(session) - if not campaigns: - text = _("admin_ads_empty") - else: - text_lines = [_("admin_ads_header")] - for camp in campaigns: - try: - stats = await ad_dal.get_campaign_stats(session, camp.ad_campaign_id) - except Exception as e_stats: - logging.error(f"Failed to calc stats for campaign {camp.ad_campaign_id}: {e_stats}") - stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0} - text_lines.append( - _( - "admin_ads_item", - id=camp.ad_campaign_id, - source=camp.source, - start_param=camp.start_param, - cost=f"{camp.cost:.2f}", - active=_("csv_yes") if camp.is_active else _("csv_no"), - starts=stats["starts"], - trials=stats["trials"], - payers=stats["payers"], - revenue=f"{stats['revenue']:.2f}", - ) - ) - text = "\n\n".join(text_lines) + totals = await ad_dal.get_totals(session) + total_cost = totals.get("cost", 0.0) + total_revenue = totals.get("revenue", 0.0) + overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}") - from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard - reply_markup = get_ads_menu_keyboard(i18n, current_lang) + total_count = await ad_dal.count_campaigns(session) + if total_count == 0: + text = overview + "\n\n" + _("admin_ads_empty") + from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard + reply_markup = get_ads_menu_keyboard(i18n, current_lang) + else: + current_page = 0 + total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) + campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE) + text = overview + "\n\n" + _("admin_ads_header") + from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard + reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages) await callback.message.edit_text(text, reply_markup=reply_markup) try: await callback.answer() @@ -59,6 +50,83 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_ pass +@router.callback_query(F.data.startswith("admin_ads:page:")) +async def ads_list_pagination(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 + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + try: + page = int(callback.data.split(":")[2]) + except Exception: + page = 0 + + totals = await ad_dal.get_totals(session) + overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}") + total_count = await ad_dal.count_campaigns(session) + total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(0, min(page, total_pages - 1)) + + campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE) + text = overview + "\n\n" + _("admin_ads_header") + from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard + reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages) + try: + await callback.message.edit_text(text, reply_markup=reply_markup) + await callback.answer() + except Exception as e: + logging.error(f"Failed to paginate ads list: {e}") + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_ads:card:")) +async def show_ad_card(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 + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + parts = callback.data.split(":") + camp_id = int(parts[2]) + back_page = int(parts[3]) if len(parts) > 3 else 0 + + camp = await ad_dal.get_campaign_by_id(session, camp_id) + if not camp: + await callback.answer(_("admin_promo_not_found"), show_alert=True) + return + try: + stats = await ad_dal.get_campaign_stats(session, camp_id) + except Exception: + stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0} + + text = _( + "admin_ads_card", + id=camp.ad_campaign_id, + source=camp.source, + start_param=camp.start_param, + cost=f"{camp.cost:.2f}", + active=_("csv_yes") if camp.is_active else _("csv_no"), + starts=stats["starts"], + trials=stats["trials"], + payers=stats["payers"], + revenue=f"{stats['revenue']:.2f}", + ) + + from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard + reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page) + try: + await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML") + await callback.answer() + except Exception as e: + logging.error(f"Failed to show ad card: {e}") + await callback.answer() + + @router.callback_query(F.data == "admin_action:ads_create") async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict): from bot.states.admin_states import AdminStates diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 85677dd..f9c4f21 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -131,6 +131,67 @@ def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: return builder.as_markup() +def get_ads_list_keyboard( + i18n_instance, + lang: str, + campaigns: list, + current_page: int, + total_pages: int, +) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + for c in campaigns: + title = f"{c.source}" + builder.button( + text=title, + callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}", + ) + + # Pagination row + row = [] + if current_page > 0: + row.append( + InlineKeyboardButton( + text="⬅️ " + _("prev_page_button", default="Prev"), + callback_data=f"admin_ads:page:{current_page - 1}", + ) + ) + row.append( + InlineKeyboardButton( + text=f"{current_page + 1}/{total_pages}", + callback_data="ads_page_display", + ) + ) + if current_page < total_pages - 1: + row.append( + InlineKeyboardButton( + text=_("next_page_button", default="Next") + " ➡️", + callback_data=f"admin_ads:page:{current_page + 1}", + ) + ) + if row: + builder.row(*row) + + builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), + callback_data="admin_action:ads_create") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1) + return builder.as_markup() + + +def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"), + callback_data=f"admin_ads:page:{back_page}") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1) + return builder.as_markup() + + def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py index 85f820c..6a9bd91 100644 --- a/db/dal/ad_dal.py +++ b/db/dal/ad_dal.py @@ -127,3 +127,38 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st } +async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int: + stmt = select(func.count(AdCampaign.ad_campaign_id)) + if only_active: + stmt = stmt.where(AdCampaign.is_active == True) + return int((await session.execute(stmt)).scalar() or 0) + + +async def list_campaigns_paged( + session: AsyncSession, *, page: int, page_size: int, only_active: bool = False +) -> List[AdCampaign]: + offset = max(0, page) * max(1, page_size) + stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size) + if only_active: + stmt = stmt.where(AdCampaign.is_active == True) + result = await session.execute(stmt) + return result.scalars().all() + + +async def get_totals(session: AsyncSession) -> Dict[str, float]: + # Total cost across all campaigns + total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0)) + total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0) + + # Total revenue from all attributed users (unique users counted across all campaigns) + revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where( + and_( + Payment.status == "succeeded", + Payment.user_id.in_(select(AdAttribution.user_id)), + ) + ) + total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0) + + return {"cost": total_cost, "revenue": total_revenue} + + diff --git a/locales/en.json b/locales/en.json index 9275745..63234a7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -420,5 +420,8 @@ "admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.", "admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.", "admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB", - "admin_ads_back_to_menu_hint": "Done. Back to Ads section:" + "admin_ads_back_to_menu_hint": "Done. Back to Ads section:", + "admin_ads_overview": "📈 Ads\n💰 Revenue: {revenue} RUB\n💸 Spent: {cost} RUB", + "back_to_ads_list_button": "⬅️ Back to list", + "admin_ads_card": "📈 Campaign #{id}\nSource: {source}\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n\n👥 Starts: {starts}\n🆓 Trials: {trials}\n💳 Payers: {payers}\n💵 Revenue: {revenue} RUB" } diff --git a/locales/ru.json b/locales/ru.json index 990fdea..ea8ffaa 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -419,5 +419,8 @@ "admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.", "admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.", "admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB", - "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:" + "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:", + "admin_ads_overview": "📈 Реклама\n💰 Пришло: {revenue} RUB\n💸 Потрачено: {cost} RUB", + "back_to_ads_list_button": "⬅️ К списку", + "admin_ads_card": "📈 Кампания #{id}\nИсточник: {source}\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n\n👥 Запустили: {starts}\n🆓 Взяли триал: {trials}\n💳 Оплатили: {payers}\n💵 Доход: {revenue} RUB" } From 9a6ff6f636ef049548d35141ba3f0b8002cd4fd9 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 15:17:01 +0300 Subject: [PATCH 37/41] Refactor pagination logic in admin ads list keyboard - Updated the pagination row to only display when multiple pages are available, improving the user interface for ad navigation. - Enhanced the inline keyboard structure for better clarity and usability in the admin panel's ads management section. --- bot/keyboards/inline/admin_keyboards.py | 39 +++++++++++++------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index f9c4f21..644cfa6 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -148,30 +148,31 @@ def get_ads_list_keyboard( callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}", ) - # Pagination row - row = [] - if current_page > 0: + # Pagination row (only when needed) + if total_pages > 1: + row = [] + if current_page > 0: + row.append( + InlineKeyboardButton( + text="⬅️ " + _("prev_page_button", default="Prev"), + callback_data=f"admin_ads:page:{current_page - 1}", + ) + ) row.append( InlineKeyboardButton( - text="⬅️ " + _("prev_page_button", default="Prev"), - callback_data=f"admin_ads:page:{current_page - 1}", + text=f"{current_page + 1}/{total_pages}", + callback_data="ads_page_display", ) ) - row.append( - InlineKeyboardButton( - text=f"{current_page + 1}/{total_pages}", - callback_data="ads_page_display", - ) - ) - if current_page < total_pages - 1: - row.append( - InlineKeyboardButton( - text=_("next_page_button", default="Next") + " ➡️", - callback_data=f"admin_ads:page:{current_page + 1}", + if current_page < total_pages - 1: + row.append( + InlineKeyboardButton( + text=_("next_page_button", default="Next") + " ➡️", + callback_data=f"admin_ads:page:{current_page + 1}", + ) ) - ) - if row: - builder.row(*row) + if row: + builder.row(*row) builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), callback_data="admin_action:ads_create") From 43de4cc08be6d4daf814ff81e98dcddb325c4b3d Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 15:46:50 +0300 Subject: [PATCH 38/41] Add cryptocurrency and stars payment handlers to subscription module - Implemented new callback handlers for cryptocurrency and stars payments, enhancing payment options for users. - Integrated error handling and user feedback mechanisms for payment processes, improving overall user experience. - Updated the pre-checkout and successful payment handling to accommodate new payment methods, ensuring seamless transaction processing. - Organized imports for better modularity and clarity in the payments module. --- bot/handlers/user/subscription/payments.py | 177 +++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 482ce22..3222a35 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -6,6 +6,8 @@ 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.services.crypto_pay_service import CryptoPayService +from bot.services.stars_service import StarsService from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal, user_billing_dal @@ -259,3 +261,178 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass +@router.callback_query(F.data.startswith("pay_crypto:")) +async def pay_crypto_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + cryptopay_service: CryptoPayService, +): + 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 cryptopay_service or not getattr(cryptopay_service, "configured", False): + 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_amount = float(price_str) + except (ValueError, IndexError): + 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) + + invoice_url = await cryptopay_service.create_invoice( + session=session, + user_id=user_id, + months=months, + amount=price_amount, + description=payment_description, + ) + + if invoice_url: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), + disable_web_page_preview=False, + ) + except Exception: + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_stars:")) +async def pay_stars_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + 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 settings.STARS_ENABLED: + 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, stars_price_str = data_payload.split(":") + months = int(months_str) + stars_price = int(stars_price_str) + except (ValueError, IndexError): + 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) + + payment_db_id = await stars_service.create_invoice( + session=session, + user_id=user_id, + months=months, + stars_price=stars_price, + description=payment_description, + ) + + if payment_db_id: + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + +@router.pre_checkout_query() +async def handle_pre_checkout_query(query: types.PreCheckoutQuery): + try: + await query.answer(ok=True) + except Exception: + # Nothing else to do here; Telegram will show an error if not answered + pass + + +@router.message(F.successful_payment) +async def handle_successful_stars_payment( + message: types.Message, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + payload = (message.successful_payment.invoice_payload + if message and message.successful_payment else "") + try: + payment_db_id_str, months_str = (payload or "").split(":", 1) + payment_db_id = int(payment_db_id_str) + months = int(months_str) + except Exception: + return + + stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 + await stars_service.process_successful_payment( + session=session, + message=message, + payment_db_id=payment_db_id, + months=months, + stars_amount=stars_amount, + i18n_data=i18n_data, + ) + From 2b592e8abe385279e5930b9380bfed2b135661c8 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 22:02:52 +0300 Subject: [PATCH 39/41] Implement auto-renew confirmation and cancellation features in subscription module - Added confirmation popups for enabling and disabling auto-renewal, enhancing user interaction and clarity. - Introduced inline keyboard options for auto-renewal management, allowing users to confirm their choices easily. - Updated notification messages to inform users about upcoming automatic charges and provide cancellation options. - Enhanced localization files to include new strings for auto-renewal features in both English and Russian. --- bot/handlers/user/subscription/core.py | 51 ++++++++++++++++++++++++++ bot/keyboards/inline/user_keyboards.py | 22 +++++++++++ bot/services/panel_webhook_service.py | 17 ++++++++- locales/en.json | 7 +++- locales/ru.json | 7 +++- 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index cb84d1b..ad499a2 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -11,6 +11,7 @@ from config.settings import Settings from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_back_to_main_menu_markup, + get_autorenew_confirm_keyboard, ) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService @@ -240,6 +241,56 @@ async def toggle_autorenew_handler( await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + # Show confirmation popup and inline buttons + confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable") + kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n) + try: + await callback.message.edit_text(confirm_text, reply_markup=kb) + except Exception: + try: + await callback.message.answer(confirm_text, reply_markup=kb) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + +@router.callback_query(F.data.startswith("autorenew:confirm:")) +async def confirm_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: + _, _, sub_id_str, enable_str = callback.data.split(":", 3) + 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: diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6fd2dae..456eae4 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -320,3 +320,25 @@ def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_inst # Back one step: return to specific payment method details builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}")) return builder.as_markup() + + +def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel") + ) + builder.row( + InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription") + ) + return builder.as_markup() + + +def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, 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=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"), + InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"), + ) + return builder.as_markup() diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index c2ec807..56b6fda 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -10,7 +10,7 @@ from typing import Optional from config.settings import Settings from .panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n -from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup +from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard from db.dal import user_dal from bot.utils.date_utils import add_months @@ -208,6 +208,21 @@ class PanelWebhookService: except Exception: logging.exception("Auto-renew trigger (24h) failed pre-check") if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: + # For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button + if days_left == 2: + async with self.async_session_factory() as session: + from db.dal import subscription_dal + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) + if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) + await self._send_message( + user_id, + lang, + "autorenew_48h_charge_tomorrow_notice", + reply_markup=cancel_kb, + user_name=first_name, + ) + return await self._send_message( user_id, lang, diff --git a/locales/en.json b/locales/en.json index 63234a7..d4a73e8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -209,6 +209,9 @@ "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", + "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", + "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", + "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", "yookassa_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}", @@ -372,8 +375,8 @@ "admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", "my_subscription_details": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", - "autorenew_enable_button": "Enable auto-renew", - "autorenew_disable_button": "Disable auto-renew", + "autorenew_enable_button": "🔄 Enable auto-renew", + "autorenew_disable_button": "🛑 Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", "payment_methods_manage_button": "💳 Payment Methods", "payment_methods_title": "💳 Payment Methods", diff --git a/locales/ru.json b/locales/ru.json index ea8ffaa..d28f4b8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -139,6 +139,9 @@ "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", + "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", + "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", + "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_promo_set_validity_days": "⏰ Установить срок (дни)", @@ -371,8 +374,8 @@ "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "my_subscription_details": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", - "autorenew_enable_button": "Включить автопродление", - "autorenew_disable_button": "Выключить автопродление", + "autorenew_enable_button": "🔄 Включить автопродление", + "autorenew_disable_button": "🛑 Отключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", "payment_methods_manage_button": "💳 Способы оплаты", "payment_methods_title": "💳 Способы оплаты", From c208541525b387ac4fadb3b2bdc176ec52dd3967 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 22:10:56 +0300 Subject: [PATCH 40/41] Add autorenew cancellation handler and logging for subscription service - Implemented a new callback handler to allow users to cancel auto-renewal of their subscriptions via a webhook button. - Added logging to track subscription status and auto-renew settings during webhook checks, improving monitoring and debugging capabilities. - Enhanced user feedback with appropriate alerts for unsupported actions and confirmation of updates to subscription settings. --- bot/handlers/user/subscription/core.py | 38 ++++++++++++++++++++++++++ bot/services/panel_webhook_service.py | 7 +++++ 2 files changed, 45 insertions(+) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index ad499a2..3b883e7 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -300,6 +300,44 @@ async def confirm_autorenew_handler( await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) +@router.callback_query(F.data == "autorenew:cancel") +async def autorenew_cancel_from_webhook_button( + 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 + + # Disable auto-renew on the active subscription (non-tribute) + from db.dal import subscription_dal + sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id) + if not sub: + try: + await callback.answer(get_text("subscription_not_active"), show_alert=True) + except Exception: + pass + return + if sub.provider == "tribute": + try: + await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + except Exception: + pass + return + await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False}) + 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, diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 56b6fda..1910676 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -213,6 +213,13 @@ class PanelWebhookService: async with self.async_session_factory() as session: from db.dal import subscription_dal sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) + logging.info( + "48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s", + user_id, + bool(sub), + getattr(sub, 'auto_renew_enabled', None) if sub else None, + getattr(sub, 'provider', None) if sub else None, + ) if sub and sub.auto_renew_enabled and sub.provider != 'tribute': cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) await self._send_message( From 79ac5cb797047b97573feac77c9cb08bcb435305 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 7 Sep 2025 12:55:54 +0500 Subject: [PATCH 41/41] Enhance error handling and logging in CryptoPayService payment processing - Added error handling for payment record creation and updates, ensuring database transactions are rolled back on failure. - Implemented logging for errors during payment record creation and updates, improving traceability and debugging capabilities. - Committed changes to ensure that payment records are properly persisted or rolled back in case of exceptions. --- bot/services/crypto_pay_service.py | 55 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index d6edd24..41261b3 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -67,18 +67,28 @@ class CryptoPayService: logging.error("CryptoPayService not configured") return None - payment_record = await payment_dal.create_payment_record( - session, - { - "user_id": user_id, - "amount": float(amount), - "currency": self.settings.CRYPTOPAY_ASSET, - "status": "pending_cryptopay", - "description": description, - "subscription_duration_months": months, - "provider": "cryptopay", - }, - ) + # Create pending payment in DB and commit to persist + try: + payment_record = await payment_dal.create_payment_record( + session, + { + "user_id": user_id, + "amount": float(amount), + "currency": self.settings.CRYPTOPAY_ASSET, + "status": "pending_cryptopay", + "description": description, + "subscription_duration_months": months, + "provider": "cryptopay", + }, + ) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + return None payload = json.dumps({ "user_id": str(user_id), "subscription_months": str(months), @@ -93,12 +103,21 @@ class CryptoPayService: description=description, payload=payload, ) - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(invoice.invoice_id), - str(invoice.status), - ) + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(invoice.invoice_id), + str(invoice.status), + ) + await session.commit() + except Exception as e_db_update: + await session.rollback() + logging.error( + f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}", + exc_info=True, + ) + return None return invoice.bot_invoice_url except Exception as e: logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)