diff --git a/.env.example b/.env.example index 5a98439..ebe2b6a 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,7 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot # YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts YOOKASSA_VAT_CODE=1 # VAT code YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle +YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox) # FreeKassa Payment Gateway Configuration FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa diff --git a/README.md b/README.md index 1849156..2528a2a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | + | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | + | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 39426cb..2d51c9c 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -212,6 +212,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession, "is_active": panel_status == "ACTIVE", "status_from_panel": panel_status, "traffic_limit_bytes": settings.user_traffic_limit_bytes, + "auto_renew_enabled": False, } created_sub = await subscription_dal.upsert_subscription( session, sub_payload @@ -436,4 +437,4 @@ async def sync_status_command_handler( else: response_text = _("admin_sync_status_never_run") - await message.answer(response_text, parse_mode="HTML") \ No newline at end of file + await message.answer(response_text, parse_mode="HTML") diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index e0ea2d1..3431b8f 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.utils.markdown import hcode, hbold -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Callable, Awaitable from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone @@ -104,7 +104,8 @@ async def user_search_prompt_handler(callback: types.CallbackQuery, await state.set_state(AdminStates.waiting_for_user_search) -def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder: +def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, + referrer_id: Optional[int] = None) -> InlineKeyboardBuilder: """Generate keyboard for user management actions""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -139,13 +140,26 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb callback_data=f"user_action:refresh:{user_id}" ) - # Row 4: Destructive action + # Row 4: Quick links + builder.button( + text=_(key="user_card_open_profile_button", + default="👤 Открыть профиль"), + url=f"tg://user?id={user_id}" + ) + if referrer_id: + builder.button( + text=_(key="user_card_open_referrer_profile_button", + default="👤 Открыть профиль пригласившего"), + url=f"tg://user?id={referrer_id}" + ) + + # Row 5: Destructive action builder.button( text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"), callback_data=f"user_action:delete_user:{user_id}" ) - # Row 5: Navigation + # Row 6: Navigation builder.button( text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), callback_data="admin_action:users_management" @@ -155,10 +169,61 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb callback_data="admin_action:main" ) - builder.adjust(2, 2, 2, 1, 2) + quick_links_width = 2 if referrer_id else 1 + builder.adjust(2, 2, 2, quick_links_width, 1, 2) return builder +def _remove_profile_link_buttons( + markup: Optional[types.InlineKeyboardMarkup]) -> Optional[types.InlineKeyboardMarkup]: + """Drop buttons that rely on tg://user links to avoid BUTTON_USER_INVALID errors.""" + if not markup or not markup.inline_keyboard: + return None + + cleaned_rows = [] + for row in markup.inline_keyboard: + filtered_row = [ + button for button in row + if not (getattr(button, "url", None) and button.url.startswith("tg://user?id=")) + ] + if filtered_row: + cleaned_rows.append(filtered_row) + + if not cleaned_rows: + return None + + return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows) + + +async def _send_with_profile_link_fallback( + sender: Callable[..., Awaitable[Any]], + *, + text: str, + markup: Optional[types.InlineKeyboardMarkup], + user_id: int, + parse_mode: Optional[str] = "HTML") -> None: + """Send text with markup and fallback if Telegram rejects tg://user buttons.""" + send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup} + if parse_mode is not None: + send_kwargs["parse_mode"] = parse_mode + + try: + await sender(**send_kwargs) + except TelegramBadRequest as exc: + message = getattr(exc, "message", "") or str(exc) + if "BUTTON_USER_INVALID" not in message: + raise + + logging.warning( + "Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.", + user_id, + message, + ) + fallback_markup = _remove_profile_link_buttons(markup) + send_kwargs["reply_markup"] = fallback_markup + await sender(**send_kwargs) + + async def format_user_card(user: User, session: AsyncSession, subscription_service: SubscriptionService, i18n_instance, lang: str, @@ -315,11 +380,18 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, try: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user_model.user_id, + i18n, + current_lang, + user_model.referred_by_id + ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=user_model.user_id, parse_mode="HTML" ) except Exception as e: @@ -580,18 +652,28 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, _settings = _Settings() referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance) user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service) - keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang) + keyboard = get_user_card_keyboard( + fresh_user.user_id, + i18n_instance, + lang, + fresh_user.referred_by_id + ) + markup = keyboard.as_markup() try: - await callback.message.edit_text( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.edit_text, + text=user_card_text, + markup=markup, + user_id=fresh_user.user_id, parse_mode="HTML" ) except Exception: - await callback.message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.answer, + text=user_card_text, + markup=markup, + user_id=fresh_user.user_id, parse_mode="HTML" ) @@ -864,11 +946,18 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo if user: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user.user_id, + i18n, + current_lang, + user.referred_by_id + ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=user.user_id, parse_mode="HTML" ) else: @@ -973,11 +1062,18 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte subscription_service = SubscriptionService(settings, panel_service) referral_service = ReferralService(settings, subscription_service, bot, i18n) user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + target_user.user_id, + i18n, + current_lang, + target_user.referred_by_id + ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, parse_mode="HTML" ) @@ -1272,22 +1368,31 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, return # Create keyboard with back to list button - keyboard = get_user_card_keyboard(user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user_id, + i18n, + current_lang, + user.referred_by_id + ) keyboard.button( text=_("admin_user_back_to_list_button", default="⬅️ К списку"), callback_data=f"admin_action:users_list:{page}" ) - keyboard.adjust(2, 2, 2, 2, 1) + quick_links_width = 2 if user.referred_by_id else 1 + keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1) # Format user card try: from bot.services.referral_service import ReferralService referral_service = ReferralService(settings, subscription_service, bot, i18n) user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) + markup = keyboard.as_markup() - await callback.message.edit_text( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.edit_text, + text=user_card_text, + markup=markup, + user_id=user.user_id, parse_mode="HTML" ) await callback.answer() diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 3c3ee9e..0dcfba8 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -170,6 +170,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, 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: + logging.exception("Failed to persist multi-card YooKassa method from webhook") except Exception: logging.exception("Failed to persist YooKassa payment method from webhook") updated_payment_record = await payment_dal.update_payment_status_by_db_id( diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index ae4f4e0..ebfc2ed 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -357,6 +357,17 @@ async def start_command_handler(message: types.Message, db_user, created = await user_dal.create_user(session, user_data_to_create) if created: + try: + await session.commit() + except Exception as commit_error: + await session.rollback() + logging.error( + f"Failed to commit new user {user_id}: {commit_error}", + exc_info=True, + ) + await message.answer(_("error_occurred_processing_request")) + return + logging.info( f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 783ab0c..2d6d9df 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -16,7 +16,7 @@ from bot.keyboards.inline.user_keyboards import ( 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.dal import subscription_dal, user_billing_dal from db.models import Subscription router = Router(name="user_subscription_core_router") @@ -455,6 +455,14 @@ async def toggle_autorenew_handler( if sub.provider == "tribute": await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + if enable: + has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) + if not has_saved_card: + try: + await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) + except Exception: + pass + return # Show confirmation popup and inline buttons confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable") @@ -505,6 +513,18 @@ async def confirm_autorenew_handler( if sub.provider == "tribute": await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + if enable: + has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) + if not has_saved_card: + try: + await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) + except Exception: + pass + try: + await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) + except Exception: + pass + return await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) await session.commit() diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 1c21005..5157552 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -408,6 +408,9 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti user_id = callback.from_user.id currency_code_for_yk = "RUB" autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) saved_methods: List = [] if autopay_enabled: try: @@ -463,7 +466,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti months=months, price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled, + save_payment_method=autopay_enabled and autopay_require_binding, back_callback=f"subscribe_period:{months}", ) try: @@ -520,6 +523,9 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti user_id = callback.from_user.id currency_code_for_yk = "RUB" autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) await _initiate_yk_payment( callback, @@ -533,7 +539,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti months=months, price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled, + save_payment_method=autopay_enabled and autopay_require_binding, back_callback=f"subscribe_period:{months}", ) try: diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 91d4379..c27a3bf 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -384,6 +384,13 @@ def get_user_card_keyboard(user_id: int, builder.button( text=_(key="user_card_ban_button"), callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}") + builder.button( + text=_( + key="user_card_open_profile_button", + default="👤 Open profile" + ), + url=f"tg://user?id={user_id}" + ) builder.button( text=_(key="user_card_back_to_banned_list_button"), callback_data=f"admin_action:view_banned:{banned_list_page}") diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 24f1a39..8a1e6c3 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -1,10 +1,11 @@ import logging import asyncio from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.text_decorations import html_decoration as hd from aiogram.exceptions import TelegramRetryAfter from datetime import datetime, timezone -from typing import Optional, Union, Dict, Any +from typing import Optional, Union, Dict, Any, Callable from config.settings import Settings from sqlalchemy.orm import sessionmaker @@ -34,8 +35,45 @@ class NotificationService: if username: base_display = f"{base_display} ({username_for_display(username)})" return base_display + + @staticmethod + def _build_profile_keyboard( + translate: Callable[..., str], + user_id: int, + referrer_id: Optional[int] = None, + ) -> InlineKeyboardMarkup: + """Create inline keyboard with links to user (and referrer) profiles.""" + buttons = [ + [ + InlineKeyboardButton( + text=translate( + "log_open_profile_link", + default="👤 Открыть профиль", + ), + url=f"tg://user?id={user_id}", + ) + ] + ] + + if referrer_id: + buttons.append([ + InlineKeyboardButton( + text=translate( + "log_open_referrer_profile_button", + default="👤 Открыть профиль пригласившего", + ), + url=f"tg://user?id={referrer_id}", + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) - async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None): + async def _send_to_log_channel( + self, + message: str, + thread_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ): """Send message to configured log channel/group using message queue""" if not self.settings.LOG_CHAT_ID: return @@ -49,6 +87,7 @@ class NotificationService: text=message, parse_mode="HTML", disable_web_page_preview=True, + reply_markup=reply_markup, message_thread_id=thread_id or self.settings.LOG_THREAD_ID ) except Exception as e: @@ -64,6 +103,8 @@ class NotificationService: "parse_mode": "HTML", "disable_web_page_preview": True } + if reply_markup: + kwargs["reply_markup"] = reply_markup # Add thread ID for supergroups if specified if final_thread_id: @@ -124,7 +165,12 @@ class NotificationService: referral_text = "" if referred_by_id: - referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id) + referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}") + referral_text = _( + "log_referral_suffix", + default=" (реферал от {referrer_link})", + referrer_link=referrer_link, + ) message = _( "log_new_user_registration", @@ -137,9 +183,10 @@ class NotificationService: referral_text=referral_text, timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ) - + # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_payment_received(self, user_id: int, amount: float, currency: str, months: int, payment_provider: str, @@ -182,7 +229,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int, username: Optional[str] = None): @@ -212,7 +260,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_trial_activation(self, user_id: int, end_date: datetime, username: Optional[str] = None): @@ -240,7 +289,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_panel_sync(self, status: str, details: str, users_processed: int, subs_synced: int, @@ -275,7 +325,7 @@ class NotificationService: details=details ) - # Send to log channel + # Send to log channel await self._send_to_log_channel(message) async def notify_suspicious_promo_attempt( @@ -308,7 +358,8 @@ class NotificationService: timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def send_custom_notification(self, message: str, to_admins: bool = False, to_log_channel: bool = True, thread_id: Optional[int] = None): diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 9e518c8..0fcaaf7 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -177,6 +177,8 @@ class ReferralService: "ACTIVE_BONUS", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, + "auto_renew_enabled": + False, } try: await subscription_dal.deactivate_other_active_subscriptions( diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index e569b0b..2c2862e 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -498,6 +498,15 @@ class SubscriptionService: session, panel_user_uuid, panel_sub_link_id ) + auto_renew_should_enable = False + if ( + provider == "yookassa" + and getattr(self.settings, "YOOKASSA_AUTOPAYMENTS_ENABLED", False) + ): + auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method( + session, user_id + ) + sub_payload = { "user_id": user_id, "panel_user_uuid": panel_user_uuid, @@ -510,7 +519,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, + "auto_renew_enabled": auto_renew_should_enable, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( @@ -616,6 +625,7 @@ class SubscriptionService: "is_active": True, "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": traffic_limit, + "auto_renew_enabled": False, } await subscription_dal.deactivate_other_active_subscriptions( session, panel_uuid, panel_sub_uuid diff --git a/config/settings.py b/config/settings.py index 561f66c..c296621 100644 --- a/config/settings.py +++ b/config/settings.py @@ -41,6 +41,10 @@ class Settings(BaseSettings): 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) + YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field( + default=True, + description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox." + ) WEBHOOK_BASE_URL: Optional[str] = None diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index a6c1039..04e825f 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id( await session.delete(method) await session.flush() return True + + +async def user_has_saved_payment_method( + session: AsyncSession, + user_id: int, + provider: str = "yookassa", +) -> bool: + """Return True if the user has at least one saved payment method.""" + try: + methods = await list_user_payment_methods(session, user_id, provider) + if methods: + return True + billing = await get_user_billing(session, user_id) + return bool(billing and billing.yookassa_payment_method_id) + except Exception: + return False diff --git a/locales/en.json b/locales/en.json index b3ffec6..553f069 100644 --- a/locales/en.json +++ b/locales/en.json @@ -210,6 +210,8 @@ "admin_user_card_title": "User Card", "user_card_ban_button": "🚫 Ban", "user_card_unban_button": "✅ Unban", + "user_card_open_profile_button": "👤 Open profile", + "user_card_open_referrer_profile_button": "👤 Referrer profile", "user_card_back_to_banned_list_button": "⬅️ Back to Ban List", "admin_logs_menu_title": "Logs Menu:", "admin_view_all_logs_button": "📜 All Message Logs", @@ -291,7 +293,9 @@ "inline_admin_financial_stats_title": "💰 Financial Statistics", "inline_system_stats_message": "🖥 Panel Statistics\n\n🟢 Online: {online}\n📊 Active: {active}\n🔴 Disabled: {disabled}\n⏰ Expired: {expired}\n⚠️ Limited: {limited}\n👥 Total users: {total}\n💾 RAM Usage: {memory:.1f}%\n📊 Week traffic: {week_traffic}\n📊 Month traffic: {month_traffic}\n🔗 Active nodes: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 System Statistics", - "log_referral_suffix": " (referral from {referrer_id})", + "log_referral_suffix": " (referral from {referrer_link})", + "log_open_profile_link": "👤 Open profile", + "log_open_referrer_profile_button": "👤 Referrer profile", "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", @@ -439,6 +443,7 @@ "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.", + "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "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 a9e97d9..b3d4714 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -220,6 +220,8 @@ "admin_user_card_title": "Карточка пользователя", "user_card_ban_button": "🚫 Заблокировать", "user_card_unban_button": "✅ Разблокировать", + "user_card_open_profile_button": "👤 Открыть профиль", + "user_card_open_referrer_profile_button": "👤 Профиль пригласившего", "user_card_back_to_banned_list_button": "⬅️ К списку забаненных", "admin_logs_menu_title": "Меню логов:", "admin_view_all_logs_button": "📜 Все логи сообщений", @@ -291,7 +293,9 @@ "inline_admin_financial_stats_title": "💰 Финансовая статистика", "inline_system_stats_message": "🖥 Статистика панели\n\n🟢 Онлайн: {online}\n📊 Активных: {active}\n🔴 Отключенных: {disabled}\n⏰ Истекшие: {expired}\n⚠️ Ограниченные: {limited}\n👥 Всего пользователей: {total}\n💾 Использование RAM: {memory:.1f}%\n📊 Трафик за неделю: {week_traffic}\n📊 Трафик за месяц: {month_traffic}\n🔗 Активных нод: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 Системная статистика", - "log_referral_suffix": " (реферал от {referrer_id})", + "log_referral_suffix": " (реферал от {referrer_link})", + "log_open_profile_link": "👤 Открыть профиль", + "log_open_referrer_profile_button": "👤 Профиль пригласившего", "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", @@ -439,6 +443,7 @@ "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", + "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", "error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",